Make ImageRepository thread-safe (#147)

This commit is contained in:
Pierre-Yves Nicolas
2026-03-25 14:35:17 +01:00
committed by GitHub
parent 516dd75e9c
commit 92914c1730
8 changed files with 66 additions and 73 deletions

View File

@@ -142,6 +142,7 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.assertj) testImplementation(libs.assertj)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View File

@@ -21,7 +21,6 @@ import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -33,27 +32,29 @@ import kotlinx.coroutines.withContext
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.ScanPage
import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
import java.util.concurrent.Executors
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() { class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
// TODO ImageRepository should be made thread-safe
private val repositoryDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode)) private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode))
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current } val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current) .stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)
private val _pages = MutableStateFlow(imageRepository.pages()) private val _pages = MutableStateFlow<List<ScanPage>>(emptyList())
init {
viewModelScope.launch {
_pages.value = imageRepository.pages()
}
}
val documentUiModel: StateFlow<DocumentUiModel> = val documentUiModel: StateFlow<DocumentUiModel> =
_pages.map { pages -> _pages.map { pages ->
DocumentUiModel( DocumentUiModel(
pageKeys = pages.map { p -> pageKeys = pages.map { it.key() }.toImmutableList(),
PageViewKey(p.id, p.manualRotation)
}.toImmutableList(),
imageLoader = ::getBitmap, imageLoader = ::getBitmap,
thumbnailLoader = ::getThumbnail, thumbnailLoader = ::getThumbnail,
) )
@@ -73,7 +74,7 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun rotateImage(id: String, clockwise: Boolean) { fun rotateImage(id: String, clockwise: Boolean) {
viewModelScope.launch { viewModelScope.launch {
val pages = withContext(repositoryDispatcher) { val pages = withContext(Dispatchers.IO) {
imageRepository.rotate(id, clockwise) imageRepository.rotate(id, clockwise)
imageRepository.pages() imageRepository.pages()
} }
@@ -83,7 +84,7 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun movePage(id: String, newIndex: Int) { fun movePage(id: String, newIndex: Int) {
viewModelScope.launch { viewModelScope.launch {
val pages = withContext(repositoryDispatcher) { val pages = withContext(Dispatchers.IO) {
imageRepository.movePage(id, newIndex) imageRepository.movePage(id, newIndex)
imageRepository.pages() imageRepository.pages()
} }
@@ -93,7 +94,7 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun deletePage(id: String) { fun deletePage(id: String) {
viewModelScope.launch { viewModelScope.launch {
val pages = withContext(repositoryDispatcher) { val pages = withContext(Dispatchers.IO) {
imageRepository.delete(id) imageRepository.delete(id)
imageRepository.pages() imageRepository.pages()
} }
@@ -104,7 +105,7 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun startNewDocument() { fun startNewDocument() {
_pages.value = persistentListOf() _pages.value = persistentListOf()
viewModelScope.launch { viewModelScope.launch {
withContext(repositoryDispatcher) { withContext(Dispatchers.IO) {
imageRepository.clear() imageRepository.clear()
} }
} }
@@ -122,10 +123,8 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun handleImageCaptured(capturedPage: CapturedPage) { fun handleImageCaptured(capturedPage: CapturedPage) {
viewModelScope.launch { viewModelScope.launch {
val sourceJpeg = withContext(Dispatchers.IO) { val pages = withContext(Dispatchers.IO) {
capturedPage.sourceJpeg.await() val sourceJpeg = capturedPage.sourceJpeg.await()
}
val pages = withContext(repositoryDispatcher) {
imageRepository.add( imageRepository.add(
capturedPage.pageJpeg, capturedPage.pageJpeg,
sourceJpeg, sourceJpeg,

View File

@@ -14,6 +14,8 @@
*/ */
package org.fairscan.app.data package org.fairscan.app.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.int import kotlinx.serialization.json.int
@@ -56,6 +58,8 @@ class ImageRepository(
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
private val mutex = Mutex()
private val metadataFile = File(scanDir, "document.json") private val metadataFile = File(scanDir, "document.json")
private val json = Json { private val json = Json {
@@ -121,17 +125,17 @@ class ImageRepository(
metadataFile.writeText(json.encodeToString(metadata)) metadataFile.writeText(json.encodeToString(metadata))
} }
fun pages(): List<ScanPage> = suspend fun pages(): List<ScanPage> = mutex.withLock {
pages.pages().mapNotNull { pages.pages().mapNotNull {
runCatching { runCatching {
val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees) val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees)
ScanPage(it.id, manualRotation, it.toMetadata()) ScanPage(it.id, manualRotation, it.toMetadata())
}.getOrNull() }.getOrNull()
} }
}
private fun page(id: String): PageV2? = pages.get(id) suspend fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata)
= mutex.withLock {
fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) {
val id = "${System.currentTimeMillis()}" val id = "${System.currentTimeMillis()}"
val fileName = "$id.jpg" val fileName = "$id.jpg"
val file = File(scanDir, fileName) val file = File(scanDir, fileName)
@@ -164,8 +168,8 @@ class ImageRepository(
fun PageV2.workFileName() = workFileName(id, manualRotationDegrees) fun PageV2.workFileName() = workFileName(id, manualRotationDegrees)
fun rotate(id: String, clockwise: Boolean) { suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
val page = pages.get(id) ?: return val page = pages.get(id) ?: return@withLock
val delta = if (clockwise) Rotation.R90 else Rotation.R270 val delta = if (clockwise) Rotation.R90 else Rotation.R270
val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta) val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
@@ -194,13 +198,6 @@ class ImageRepository(
return (if (file.exists()) file else null)?.readBytes() return (if (file.exists()) file else null)?.readBytes()
} }
fun jpegBytes(id: String): ByteArray? {
val page = page(id)
if (page == null) return null
val file = File(scanDir, page.workFileName())
return (if (file.exists()) file else null)?.readBytes()
}
fun sourceJpegBytes(id: String): ByteArray? { fun sourceJpegBytes(id: String): ByteArray? {
val file = getSourceFile(id) val file = getSourceFile(id)
return if (file.exists()) file.readBytes() else null return if (file.exists()) file.readBytes() else null
@@ -211,10 +208,7 @@ class ImageRepository(
} }
fun getThumbnail(key: PageViewKey): ByteArray? { fun getThumbnail(key: PageViewKey): ByteArray? {
val thumbFile = getThumbnailFile(key) val thumbFile = File(thumbnailDir, workFileName(key))
if (thumbFile == null) {
return null
}
if (!thumbFile.exists()) { if (!thumbFile.exists()) {
val workFile = File(scanDir, workFileName(key)) val workFile = File(scanDir, workFileName(key))
if (!workFile.exists()) return null if (!workFile.exists()) return null
@@ -228,16 +222,12 @@ class ImageRepository(
transformations.resize(originalFile, thumbFile, thumbnailSizePx) transformations.resize(originalFile, thumbFile, thumbnailSizePx)
} }
private fun getThumbnailFile(key: PageViewKey): File? { suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {
return File(thumbnailDir, workFileName(key))
}
fun movePage(id: String, newIndex: Int) {
pages.move(id, newIndex) pages.move(id, newIndex)
saveMetadata() saveMetadata()
} }
fun delete(id: String) { suspend fun delete(id: String) = mutex.withLock {
pages.delete(id) pages.delete(id)
saveMetadata() saveMetadata()
@@ -250,7 +240,7 @@ class ImageRepository(
?.forEach { it.delete() } ?.forEach { it.delete() }
} }
fun clear() { suspend fun clear() = mutex.withLock {
pages.clear() pages.clear()
saveMetadata() // "empty" json file saveMetadata() // "empty" json file

View File

@@ -20,20 +20,19 @@ import org.fairscan.imageprocessing.resizeForMaxPixels
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.MatOfByte import org.opencv.core.MatOfByte
import org.opencv.core.MatOfInt
import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgcodecs.Imgcodecs
fun jpegsForExport( suspend fun jpegsForExport(
imageRepository: ImageRepository, imageRepository: ImageRepository,
exportQuality: ExportQuality exportQuality: ExportQuality
): Sequence<ByteArray> { ): Sequence<ByteArray> {
val pages = imageRepository.pages().asSequence() val pages = imageRepository.pages().asSequence()
return when (exportQuality) { return when (exportQuality) {
ExportQuality.BALANCED -> pages.mapNotNull { imageRepository.jpegBytes(it.id) } ExportQuality.BALANCED -> pages.mapNotNull { imageRepository.jpegBytes(it.key()) }
ExportQuality.LOW -> pages.mapNotNull { page -> ExportQuality.LOW -> pages.mapNotNull { page ->
imageRepository.jpegBytes(page.id)?.let { jpeg -> imageRepository.jpegBytes(page.key())?.let { jpeg ->
resizeJpegBytesForMaxPixels( resizeJpegBytesForMaxPixels(
jpegBytes = jpeg, jpegBytes = jpeg,
maxPixels = exportQuality.maxPixels.toDouble(), maxPixels = exportQuality.maxPixels.toDouble(),
@@ -49,7 +48,7 @@ fun jpegsForExport(
if (sourceJpegBytes != null && pageMetadata != null) if (sourceJpegBytes != null && pageMetadata != null)
prepareJpegForHigh(sourceJpegBytes, pageMetadata, manualRotation, exportQuality) prepareJpegForHigh(sourceJpegBytes, pageMetadata, manualRotation, exportQuality)
else else
imageRepository.jpegBytes(page.id) imageRepository.jpegBytes(page.key())
} }
} }
} }

View File

@@ -26,7 +26,9 @@ data class ScanPage(
val id: String, val id: String,
val manualRotation: Rotation, val manualRotation: Rotation,
val metadata: PageMetadata?, val metadata: PageMetadata?,
) ) {
fun key(): PageViewKey = PageViewKey(id, manualRotation)
}
data class PageViewKey( data class PageViewKey(
val pageId: String, val pageId: String,

View File

@@ -119,10 +119,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
} }
} }
private fun currentPageKeys(): ImmutableList<PageViewKey> = private suspend fun currentPageKeys(): ImmutableList<PageViewKey> =
imageRepository.pages().map { imageRepository.pages().map { it.key() }.toImmutableList()
PageViewKey(it.id, it.manualRotation)
}.toImmutableList()
fun prepareExportIfNeeded() { fun prepareExportIfNeeded() {
ensureValidFilename() ensureValidFilename()

View File

@@ -16,6 +16,7 @@ package org.fairscan.app.data
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.PageViewKey
@@ -60,7 +61,7 @@ class ImageRepositoryTest {
} }
@Test @Test
fun add_image() { fun add_image() = runTest {
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
val bytes = byteArrayOf(101, 102, 103) val bytes = byteArrayOf(101, 102, 103)
@@ -82,7 +83,7 @@ class ImageRepositoryTest {
} }
@Test @Test
fun delete_image() { fun delete_image() = runTest {
val repo = repo() val repo = repo()
val bytes = byteArrayOf(101, 102, 103) val bytes = byteArrayOf(101, 102, 103)
repo.add(bytes, byteArrayOf(51), metadata1) repo.add(bytes, byteArrayOf(51), metadata1)
@@ -98,28 +99,28 @@ class ImageRepositoryTest {
} }
@Test @Test
fun delete_unknown_id() { fun delete_unknown_id() = runTest {
val repo = repo() val repo = repo()
repo.delete("x") repo.delete("x")
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
} }
@Test @Test
fun `should find existing files at initialization with no json`() { fun `should find existing files at initialization with no json`() = runTest {
scanDir().mkdirs() scanDir().mkdirs()
File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103))
assertThat(repo().imageIds()).containsExactly("1") assertThat(repo().imageIds()).containsExactly("1")
} }
@Test @Test
fun `should find existing files at initialization if json is invalid`() { fun `should find existing files at initialization if json is invalid`() = runTest {
writeDocumentDotJson("xxx") writeDocumentDotJson("xxx")
File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103))
assertThat(repo().imageIds()).containsExactly("1") assertThat(repo().imageIds()).containsExactly("1")
} }
@Test @Test
fun `no json and two files with same id`() { fun `no json and two files with same id`() = runTest {
scanDir().mkdirs() scanDir().mkdirs()
File(scanDir(), "1768153479486.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(scanDir(), "1768153479486.jpg").writeBytes(byteArrayOf(101, 102, 103))
File(scanDir(), "1768153479486-270.jpg").writeBytes(byteArrayOf(105, 106, 107)) File(scanDir(), "1768153479486-270.jpg").writeBytes(byteArrayOf(105, 106, 107))
@@ -128,24 +129,24 @@ class ImageRepositoryTest {
} }
@Test @Test
fun `should find existing files at initialization with no json and with rotation`() { fun `should find existing files at initialization with no json and with rotation`() = runTest {
scanDir().mkdirs() scanDir().mkdirs()
val bytes = byteArrayOf(101, 102, 103) val bytes = byteArrayOf(101, 102, 103)
File(scanDir(), "1-90.jpg").writeBytes(bytes) File(scanDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes("1")).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes)
} }
@Test @Test
fun `should filter pages in json at initialization`() { fun `should filter pages in json at initialization`() = runTest {
writeDocumentDotJson("""{"pages":[{"file":"1.jpg"}, {"file":"2.jpg"}]}""") writeDocumentDotJson("""{"pages":[{"file":"1.jpg"}, {"file":"2.jpg"}]}""")
File(scanDir(), "2.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(scanDir(), "2.jpg").writeBytes(byteArrayOf(101, 102, 103))
assertThat(repo().imageIds()).containsExactly("2") assertThat(repo().imageIds()).containsExactly("2")
} }
@Test @Test
fun `should rename rotated files with no base file`() { fun `should rename rotated files with no base file`() = runTest {
scanDir().mkdirs() scanDir().mkdirs()
val bytes = byteArrayOf(105, 106, 107) val bytes = byteArrayOf(105, 106, 107)
File(scanDir(), "123-90.jpg").writeBytes(bytes) File(scanDir(), "123-90.jpg").writeBytes(bytes)
@@ -157,24 +158,24 @@ class ImageRepositoryTest {
} }
@Test @Test
fun `should rename rotated files with no base file but listed in json`() { fun `should rename rotated files with no base file but listed in json`() = runTest {
writeDocumentDotJson("""{"pages":[{"file":"1-90.jpg"}]}""") writeDocumentDotJson("""{"pages":[{"file":"1-90.jpg"}]}""")
val bytes = byteArrayOf(105, 106, 107) val bytes = byteArrayOf(105, 106, 107)
File(scanDir(), "1-90.jpg").writeBytes(bytes) File(scanDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes("1")).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes)
} }
@Test @Test
fun `should return null on invalid id`() { fun `should return null on invalid id`() = runTest {
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
assertThat(repo.jpegBytes("x")).isNull() assertThat(repo.jpegBytes(PageViewKey("x", R0))).isNull()
} }
@Test @Test
fun `clear should delete pages`() { fun `clear should delete pages`() = runTest {
val bytes = byteArrayOf(101, 102, 103) val bytes = byteArrayOf(101, 102, 103)
val repo1 = repo() val repo1 = repo()
repo1.add(bytes, byteArrayOf(51), metadata1) repo1.add(bytes, byteArrayOf(51), metadata1)
@@ -189,7 +190,7 @@ class ImageRepositoryTest {
} }
@Test @Test
fun rotate() { fun rotate() = runTest {
val repo = repo() val repo = repo()
repo.add(byteArrayOf(101, 102, 103), byteArrayOf(51), metadata1) repo.add(byteArrayOf(101, 102, 103), byteArrayOf(51), metadata1)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1) assertThat(repo.pages().last().metadata).isEqualTo(metadata1)
@@ -207,14 +208,14 @@ class ImageRepositoryTest {
} }
@Test @Test
fun rotate_unknown_id() { fun rotate_unknown_id() = runTest {
val repo = repo() val repo = repo()
repo.rotate("x", true) repo.rotate("x", true)
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
} }
@Test @Test
fun movePage() { fun movePage() = runTest {
val repo = repo() val repo = repo()
repo.add(byteArrayOf(101), byteArrayOf(51), metadata1) repo.add(byteArrayOf(101), byteArrayOf(51), metadata1)
Thread.sleep(1L) // to avoid file name clashes Thread.sleep(1L) // to avoid file name clashes
@@ -229,7 +230,7 @@ class ImageRepositoryTest {
} }
@Test @Test
fun move_unknown_id() { fun move_unknown_id() = runTest {
val repo = repo() val repo = repo()
repo.movePage("x", 0) repo.movePage("x", 0)
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
@@ -250,7 +251,7 @@ class ImageRepositoryTest {
} }
@Test @Test
fun `pages with invalid metadata should be skipped`() { fun `pages with invalid metadata should be skipped`() = runTest {
val bytes = byteArrayOf(105, 106, 107) val bytes = byteArrayOf(105, 106, 107)
writeDocumentDotJson("""{"version":2, "pages":[{"id":"1", "manualRotationDegrees":90}]}""") writeDocumentDotJson("""{"version":2, "pages":[{"id":"1", "manualRotationDegrees":90}]}""")
@@ -265,7 +266,7 @@ class ImageRepositoryTest {
} }
@Test @Test
fun last_added_source_file() { fun last_added_source_file() = runTest {
val repo = repo() val repo = repo()
assertThat(repo.lastAddedSourceFile()).isNull() assertThat(repo.lastAddedSourceFile()).isNull()
repo.add(byteArrayOf(101), byteArrayOf(51), metadata1) repo.add(byteArrayOf(101), byteArrayOf(51), metadata1)
@@ -298,6 +299,6 @@ class ImageRepositoryTest {
File(scanDir(), "document.json").writeText(json) File(scanDir(), "document.json").writeText(json)
} }
fun ImageRepository.imageIds(): PersistentList<String> = suspend fun ImageRepository.imageIds(): PersistentList<String> =
pages().map { it.id }.toPersistentList() pages().map { it.id }.toPersistentList()
} }

View File

@@ -24,6 +24,7 @@ protobufJavaLite = "4.34.1"
kotlinSerialization = "1.10.0" kotlinSerialization = "1.10.0"
reorderable = "3.0.0" reorderable = "3.0.0"
jetbrainsKotlinJvm = "2.3.10" jetbrainsKotlinJvm = "2.3.10"
coroutines-test = "1.10.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -62,6 +63,8 @@ zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zooma
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-test" }
assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" } assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" }