feat(media): Phase 0+1 — Coil3 image loading + inline images in notes

- DesktopImageLoaderSetup: Coil3 with explicit memory cache (25% heap,
  max 512MB), persistent disk cache (OS-appropriate paths), SVG decoder
- DesktopBlurHashFetcher: blurhash placeholder images via Skia Bitmap
- DesktopBase64Fetcher: base64 data: URI image decoding
- SkiaGifDecoder: GIF first-frame rendering via Skia Codec
- MediaAspectRatioCache: thread-safe LruCache for aspect ratios
- NoteCard: inline AsyncImage for image URLs, stripped from text
- Added coil-compose, coil-okhttp, coil-svg, vlcj, commons-imaging,
  androidx-collection deps to desktopApp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-16 10:39:43 +02:00
parent 7a3b1afacc
commit 212dda40ca
9 changed files with 467 additions and 7 deletions
+12
View File
@@ -46,8 +46,20 @@ dependencies {
// JSON // JSON
implementation(libs.jackson.module.kotlin) implementation(libs.jackson.module.kotlin)
// Image loading (Coil3 — explicit because commons uses implementation, not api)
implementation(libs.coil.compose)
implementation(libs.coil.okhttp)
implementation(libs.coil.svg)
// Video playback
implementation(libs.vlcj)
// EXIF stripping (lossless)
implementation(libs.commons.imaging)
// Collections // Collections
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)
implementation(libs.androidx.collection)
// SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps
implementation("org.slf4j:slf4j-nop:2.0.16") implementation("org.slf4j:slf4j-nop:2.0.16")
@@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
@@ -137,7 +138,8 @@ sealed class DesktopScreen {
data object Settings : DesktopScreen() data object Settings : DesktopScreen()
} }
fun main() = fun main() {
DesktopImageLoaderSetup.setup()
application { application {
val windowState = val windowState =
rememberWindowState( rememberWindowState(
@@ -384,6 +386,7 @@ fun main() =
) )
} }
} }
}
@Composable @Composable
fun App( fun App(
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.model
import androidx.collection.LruCache
object MediaAspectRatioCache {
private val cache = LruCache<String, Float>(1000)
fun get(url: String): Float? = cache[url]
fun put(
url: String,
ratio: Float,
) {
cache.put(url, ratio)
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.Uri
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
import com.vitorpamplona.amethyst.commons.richtext.Base64Image
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
import java.awt.image.BufferedImage
@Stable
class DesktopBase64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult? =
runCatching {
val platformImage = Base64Image.toPlatformImage(data.toString())
val bufferedImage = platformImage.toBufferedImage()
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}.getOrNull()
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? =
if (data.scheme == "data") {
DesktopBase64Fetcher(options, data)
} else {
null
}
}
object BKeyer : Keyer<Uri> {
override fun key(
data: Uri,
options: Options,
): String? =
if (data.scheme == "data") {
sha256(data.toString().toByteArray()).toHexKey()
} else {
null
}
}
}
internal fun bufferedImageToSkiaBitmap(bi: BufferedImage): Bitmap {
val w = bi.width
val h = bi.height
val pixels = IntArray(w * h)
bi.getRGB(0, 0, w, h, pixels, 0, w)
val bitmap = Bitmap()
bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
bitmap.installPixels(convertArgbToBgra(pixels))
bitmap.setImmutable()
return bitmap
}
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
data class BlurhashWrapper(
val blurhash: String,
)
@Stable
class DesktopBlurHashFetcher(
private val options: Options,
private val data: BlurhashWrapper,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val hash = data.blurhash
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
val bufferedImage = platformImage.toBufferedImage()
val w = bufferedImage.width
val h = bufferedImage.height
val pixels = IntArray(w * h)
bufferedImage.getRGB(0, 0, w, h, pixels, 0, w)
val bitmap = Bitmap()
bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
bitmap.installPixels(convertArgbToBgra(pixels))
bitmap.setImmutable()
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<BlurhashWrapper> {
override fun create(
data: BlurhashWrapper,
options: Options,
imageLoader: ImageLoader,
): Fetcher = DesktopBlurHashFetcher(options, data)
}
object BKeyer : Keyer<BlurhashWrapper> {
override fun key(
data: BlurhashWrapper,
options: Options,
): String = data.blurhash
}
}
internal fun convertArgbToBgra(pixels: IntArray): ByteArray {
val bytes = ByteArray(pixels.size * 4)
for (i in pixels.indices) {
val argb = pixels[i]
val a = (argb shr 24) and 0xFF
val r = (argb shr 16) and 0xFF
val g = (argb shr 8) and 0xFF
val b = argb and 0xFF
val offset = i * 4
bytes[offset] = b.toByte()
bytes[offset + 1] = g.toByte()
bytes[offset + 2] = r.toByte()
bytes[offset + 3] = a.toByte()
}
return bytes
}
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.images
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.size.Precision
import coil3.svg.SvgDecoder
import okio.Path.Companion.toOkioPath
import java.io.File
object DesktopImageLoaderSetup {
@OptIn(DelicateCoilApi::class)
fun setup() {
SingletonImageLoader.setUnsafe(createImageLoader())
}
fun createImageLoader(): ImageLoader =
ImageLoader
.Builder(PlatformContext.INSTANCE)
.memoryCache { newMemoryCache() }
.diskCache { newDiskCache() }
.precision(Precision.INEXACT)
.components {
add(SvgDecoder.Factory())
add(SkiaGifDecoder.Factory())
add(DesktopBase64Fetcher.Factory)
add(DesktopBlurHashFetcher.Factory)
add(DesktopBase64Fetcher.BKeyer)
add(DesktopBlurHashFetcher.BKeyer)
}.build()
private fun newMemoryCache(): MemoryCache {
val maxMemory = Runtime.getRuntime().maxMemory()
val cacheSize = (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024)
return MemoryCache
.Builder()
.maxSizeBytes(cacheSize)
.strongReferencesEnabled(true)
.build()
}
private fun newDiskCache(): DiskCache =
DiskCache
.Builder()
.directory(cacheDir().resolve("AmethystDesktop/image_cache").toOkioPath())
.maxSizeBytes(512L * 1024 * 1024)
.build()
private fun cacheDir(): File {
val os = System.getProperty("os.name").lowercase()
return when {
"mac" in os -> {
File(System.getProperty("user.home"), "Library/Caches")
}
"win" in os -> {
File(
System.getenv("LOCALAPPDATA")
?: System.getProperty("user.home"),
)
}
else -> {
File(
System.getenv("XDG_CACHE_HOME")
?: "${System.getProperty("user.home")}/.cache",
)
}
}
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.images
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Codec
import org.jetbrains.skia.Data
class SkiaGifDecoder(
private val source: ImageSource,
) : Decoder {
override suspend fun decode(): DecodeResult {
val bytes = source.source().use { it.readByteArray() }
val data = Data.makeFromBytes(bytes)
val codec = Codec.makeFromData(data)
val bitmap = Bitmap()
bitmap.allocN32Pixels(codec.width, codec.height)
codec.readPixels(bitmap, 0)
bitmap.setImmutable()
return DecodeResult(
image = bitmap.asImage(),
isSampled = false,
)
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader,
): Decoder? {
val mimeType = result.mimeType ?: return null
if (mimeType != "image/gif") return null
return SkiaGifDecoder(result.source)
}
}
}
@@ -27,8 +27,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -38,12 +40,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.richtext.Urls
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
@@ -73,6 +79,30 @@ fun NoteCard(
onAuthorClick: ((String) -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null,
) { ) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls =
remember(urls) {
urls.withScheme.filter { RichTextParser.isImageUrl(it) }
}
val strippedContent =
remember(note.content, imageUrls) {
var text = note.content
for (url in imageUrls) {
text = text.replace(url, "").trim()
}
text
}
val strippedUrls =
remember(urls, imageUrls) {
val imageSet = imageUrls.toSet()
Urls(
withScheme = urls.withScheme - imageSet,
withoutScheme = urls.withoutScheme,
emails = urls.emails,
bech32s = urls.bech32s,
relayUrls = urls.relayUrls,
blossomUris = urls.blossomUris,
)
}
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -125,11 +155,35 @@ fun NoteCard(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
RichTextContent( if (strippedContent.isNotBlank()) {
content = note.content, RichTextContent(
urls = urls, content = strippedContent,
modifier = Modifier.fillMaxWidth(), urls = strippedUrls,
) modifier = Modifier.fillMaxWidth(),
)
}
// Inline images
if (imageUrls.isNotEmpty()) {
if (strippedContent.isNotBlank()) {
Spacer(Modifier.height(8.dp))
}
for (url in imageUrls) {
AsyncImage(
model = url,
contentDescription = null,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.FillWidth,
)
if (url != imageUrls.last()) {
Spacer(Modifier.height(4.dp))
}
}
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -171,7 +225,6 @@ fun RichTextContent(
val annotatedText = val annotatedText =
buildAnnotatedString { buildAnnotatedString {
var lastIndex = 0 var lastIndex = 0
// TODO: User the other urls.
val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) } val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) }
for (url in sortedUrls) { for (url in sortedUrls) {
+4
View File
@@ -60,6 +60,8 @@ unifiedpush = "3.0.10"
vico-charts-compose = "3.0.3" vico-charts-compose = "3.0.3"
zelory = "3.0.1" zelory = "3.0.1"
zoomable = "2.11.1" zoomable = "2.11.1"
vlcj = "4.8.3"
commonsImaging = "1.0.0-alpha5"
zxing = "3.5.4" zxing = "3.5.4"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.5.1" windowCoreAndroid = "1.5.1"
@@ -121,6 +123,8 @@ coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif", version.ref = "coil"
coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" } coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" }
coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }