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

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

View File

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

View File

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

View File

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

View File

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