From 446b915d59bd9c0510258dea3f76fe19377cd69f Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:46:35 +0100 Subject: [PATCH] ImageRepository: rotations and thumbnails are not stored on disk anymore --- .../java/org/fairscan/app/SessionViewModel.kt | 11 +- .../org/fairscan/app/data/ImageRepository.kt | 227 +++++++++--------- .../fairscan/app/data/ImageTransformations.kt | 6 +- .../fairscan/app/domain/ExportPreparation.kt | 14 +- .../platform/OpenCvImageTransformations.kt | 25 +- .../fairscan/app/data/ImageRepositoryTest.kt | 66 ++--- .../org/fairscan/imageprocessing/Utils.kt | 11 + 7 files changed, 179 insertions(+), 181 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/SessionViewModel.kt b/app/src/main/java/org/fairscan/app/SessionViewModel.kt index 7b0a932..ef97eb7 100644 --- a/app/src/main/java/org/fairscan/app/SessionViewModel.kt +++ b/app/src/main/java/org/fairscan/app/SessionViewModel.kt @@ -17,6 +17,8 @@ package org.fairscan.app import android.app.Application import android.content.Context import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope import org.fairscan.app.data.ImageRepository import org.fairscan.app.platform.OpenCvTransformations import java.io.File @@ -39,7 +41,8 @@ class SessionViewModel( private val sessionContainer = ScanSessionContainer( context = app, - scanRootDir = sessionDir + scanRootDir = sessionDir, + scope = viewModelScope, ) val imageRepository: ImageRepository = sessionContainer.imageRepository @@ -55,7 +58,8 @@ class SessionViewModel( class ScanSessionContainer( context: Context, - scanRootDir: File + scanRootDir: File, + scope: CoroutineScope, ) { private val density = context.resources.displayMetrics.density private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt() @@ -63,6 +67,7 @@ class ScanSessionContainer( val imageRepository = ImageRepository( scanRootDir, OpenCvTransformations(), - thumbnailSizePx + thumbnailSizePx, + scope, ) } diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index e03ed85..f8d9e1d 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -14,8 +14,15 @@ */ package org.fairscan.app.data +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.int @@ -29,9 +36,10 @@ import org.fairscan.app.domain.ScanPage import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import java.io.File +import java.util.Collections const val SOURCE_DIR_NAME = "sources" -const val SCAN_DIR_NAME = "scanned_pages" +const val PROCESSED_DIR_NAME = "scanned_pages" const val THUMBNAIL_DIR_NAME = "thumbnails" /** @@ -44,34 +52,32 @@ class ImageRepository( scanRootDir: File, val transformations: ImageTransformations, private val thumbnailSizePx: Int, + private val scope: CoroutineScope, ) { - - private val sourceDir: File = File(scanRootDir, SOURCE_DIR_NAME).apply { - if (!exists()) mkdirs() - } - - private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply { - if (!exists()) mkdirs() - } - - private val thumbnailDir: File = File(scanRootDir, THUMBNAIL_DIR_NAME).apply { - if (!exists()) mkdirs() - } + private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() } + private val processedDir = File(scanRootDir, PROCESSED_DIR_NAME).apply { mkdirs() } + private val thumbnailDir = File(scanRootDir, THUMBNAIL_DIR_NAME) private val mutex = Mutex() - private val metadataFile = File(scanDir, "document.json") - - private val json = Json { - prettyPrint = false - encodeDefaults = true - } - + private val metadataFile = File(processedDir, "document.json") + private val json = Json { prettyPrint = false; encodeDefaults = true } private var pages: PageStore = PageStore(loadPages()) + private val imageCache = createLruCache>(maxEntries = 50) + private val thumbnailCache = createLruCache>(maxEntries = 200) + + private fun createLruCache(maxEntries: Int): MutableMap = + Collections.synchronizedMap(object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > maxEntries + }) + + // --- Metadata --- + private fun loadPages(): MutableList { + thumbnailDir.deleteRecursively() // clean up dir that was used in older versions normalizeLegacyFiles() - val filesOnDisk = scanDir.listFiles() + val filesOnDisk = processedDir.listFiles() ?.filter { it.extension == "jpg" } ?.map { it.name } ?.toSet() @@ -84,7 +90,7 @@ class ImageRepository( return when { metadataPages != null -> metadataPages - .filter { it.workFileName() in filesOnDisk } + .filter { "${it.id}.jpg" in filesOnDisk } .toMutableList() else -> filesOnDisk @@ -107,11 +113,8 @@ class ImageRepository( } } - private fun migrateFromV1(meta: DocumentMetadataV1): MutableList { - return meta.pages.map { old -> - pageFromLegacyFileName(old.file) - }.toMutableList() - } + private fun migrateFromV1(meta: DocumentMetadataV1): MutableList = + meta.pages.map { pageFromLegacyFileName(it.file) }.toMutableList() private fun pageFromLegacyFileName(fileName: String): PageV2 { val name = fileName.removeSuffix(".jpg") @@ -125,6 +128,8 @@ class ImageRepository( metadataFile.writeText(json.encodeToString(metadata)) } + // --- Main API --- + suspend fun pages(): List = mutex.withLock { pages.pages().mapNotNull { runCatching { @@ -134,94 +139,91 @@ class ImageRepository( } } - suspend fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) - = mutex.withLock { - val id = "${System.currentTimeMillis()}" - val fileName = "$id.jpg" - val file = File(scanDir, fileName) - file.writeBytes(pageBytes) - writeThumbnail(file) - File(sourceDir, fileName).writeBytes(sourceBytes) - pages.addOrReplace( - PageV2( - id = id, - quad = metadata.normalizedQuad.toSerializable(), - baseRotationDegrees = metadata.baseRotation.degrees, - manualRotationDegrees = Rotation.R0.degrees, - isColored = metadata.isColored + suspend fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) = + mutex.withLock { + val id = "${System.currentTimeMillis()}" + val fileName = "$id.jpg" + File(processedDir, fileName).writeBytes(pageBytes) + File(sourceDir, fileName).writeBytes(sourceBytes) + pages.addOrReplace( + PageV2( + id = id, + quad = metadata.normalizedQuad.toSerializable(), + baseRotationDegrees = metadata.baseRotation.degrees, + manualRotationDegrees = Rotation.R0.degrees, + isColored = metadata.isColored + ) ) - ) - saveMetadata() - } - - private fun workFileName(key: PageViewKey): String = - workFileName(key.pageId, key.rotation) - - private fun workFileName(pageId: String, manualRotation: Rotation): String = - workFileName(pageId, manualRotation.degrees) - - private fun workFileName(pageId: String, manualRotationDegrees: Int): String = - when (manualRotationDegrees) { - 0 -> "$pageId.jpg" - else -> "$pageId-${manualRotationDegrees}.jpg" + saveMetadata() + // Pre-populate cache for R0 + val key = PageViewKey(id, Rotation.R0) + imageCache.put(key, CompletableDeferred(pageBytes)) } - fun PageV2.workFileName() = workFileName(id, manualRotationDegrees) - 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) - - val inputFile = File(scanDir, "$id.jpg") - val outputFile = File(scanDir, workFileName(id, newRotation.degrees)) - - if (inputFile.exists() && !outputFile.exists()) { - transformations.rotate( - inputFile, - outputFile, - newRotation.degrees, - ExportQuality.BALANCED.jpegQuality - ) - } - pages.update(id) { it.copy(manualRotationDegrees = newRotation.degrees) } - saveMetadata() } - fun jpegBytes(key: PageViewKey): ByteArray? { - val file = File(scanDir, workFileName(key)) - return (if (file.exists()) file else null)?.readBytes() + fun jpegBytes(key: PageViewKey): ByteArray? = runBlocking(Dispatchers.IO) { + getOrCompute(imageCache, key, ::computeProcessedImage) } + fun getThumbnail(key: PageViewKey): ByteArray? = runBlocking(Dispatchers.IO) { + getOrCompute(thumbnailCache, key, ::computeThumbnail) + } + + // --- Cache compute functions --- + + private suspend fun getOrCompute( + cache: MutableMap>, + key: PageViewKey, + compute: suspend (PageViewKey) -> ByteArray? + ): ByteArray? { + val deferred = cache.computeIfAbsent(key) { k -> + scope.async(Dispatchers.IO) { compute(k) } + } + try { + return deferred.await() + } catch (e: Exception) { + cache.remove(key, deferred) + throw e + } + } + + private suspend fun computeProcessedImage(key: PageViewKey): ByteArray? = + withContext(Dispatchers.IO) { + val baseFile = File(processedDir, "${key.pageId}.jpg") + if (!baseFile.exists()) return@withContext null + if (key.rotation == Rotation.R0) { + baseFile.readBytes() + } else { + transformations.rotate( + baseFile.readBytes(), + key.rotation.degrees, + ExportQuality.BALANCED.jpegQuality) + } + } + + private suspend fun computeThumbnail(key: PageViewKey): ByteArray? = + withContext(Dispatchers.IO) { + val imageBytes = getOrCompute(imageCache, key, ::computeProcessedImage) + ?: return@withContext null + transformations.resize(imageBytes, thumbnailSizePx) + } + + // --- Other operations --- + fun sourceJpegBytes(id: String): ByteArray? { - val file = getSourceFile(id) + val file = File(sourceDir, "$id.jpg") return if (file.exists()) file.readBytes() else null } - private fun getSourceFile(id: String): File { - return File(sourceDir, "$id.jpg") - } - - fun getThumbnail(key: PageViewKey): ByteArray? { - val thumbFile = File(thumbnailDir, workFileName(key)) - if (!thumbFile.exists()) { - val workFile = File(scanDir, workFileName(key)) - if (!workFile.exists()) return null - writeThumbnail(workFile) - } - return if (thumbFile.exists()) thumbFile.readBytes() else null - } - - private fun writeThumbnail(originalFile: File) { - val thumbFile = File(thumbnailDir, originalFile.name) - transformations.resize(originalFile, thumbFile, thumbnailSizePx) - } - suspend fun movePage(id: String, newIndex: Int) = mutex.withLock { pages.move(id, newIndex) saveMetadata() @@ -230,31 +232,24 @@ class ImageRepository( suspend fun delete(id: String) = mutex.withLock { pages.delete(id) saveMetadata() - - getSourceFile(id).delete() - scanDir.listFiles() - ?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") } - ?.forEach { it.delete() } - thumbnailDir.listFiles() - ?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") } + File(sourceDir, "$id.jpg").delete() + processedDir.listFiles() + ?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") } ?.forEach { it.delete() } + // No need to clean caches: stale entries will be evicted by LRU } suspend fun clear() = mutex.withLock { pages.clear() - saveMetadata() // "empty" json file - - thumbnailDir.listFiles()?.forEach { - file -> file.delete() - } - scanDir.listFiles()?.forEach { - file -> file.delete() - } - sourceDir.listFiles()?.forEach { - file -> file.delete() - } + saveMetadata() + sourceDir.listFiles()?.forEach { it.delete() } + processedDir.listFiles()?.forEach { it.delete() } + synchronized(imageCache) { imageCache.clear() } + synchronized(thumbnailCache) { thumbnailCache.clear() } } + // --- Legacy migration --- + data class DiskPageFiles( val base: File?, val rotated: List @@ -265,7 +260,7 @@ class ImageRepository( // and discard the others. We intentionally sacrifice exact rotation // fidelity to restore a coherent model. private fun normalizeLegacyFiles() { - val jpgs = scanDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty() + val jpgs = processedDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty() val byId = jpgs.groupBy { file -> val name = file.name.removeSuffix(".jpg") val dash = name.lastIndexOf('-') @@ -280,7 +275,7 @@ class ImageRepository( if (files.base == null && files.rotated.isNotEmpty()) { val sortedRotatedFiles = files.rotated.sortedBy { it.name } val legacyFile = sortedRotatedFiles.first() - val target = File(scanDir, "$id.jpg") + val target = File(processedDir, "$id.jpg") if (legacyFile.renameTo(target)) { sortedRotatedFiles.drop(1).forEach { it.delete() } } diff --git a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt index 4d1edca..3b0eb29 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -14,12 +14,10 @@ */ package org.fairscan.app.data -import java.io.File - interface ImageTransformations { - fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) + fun rotate(input: ByteArray, rotationDegrees: Int, jpegQuality: Int): ByteArray - fun resize(inputFile: File, outputFile: File, maxSize: Int) + fun resize(input: ByteArray, maxSize: Int): ByteArray } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index ab4d534..59d663a 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -15,13 +15,12 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository +import org.fairscan.imageprocessing.decodeJpeg import org.fairscan.imageprocessing.encodeJpeg import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.scaledTo import org.opencv.core.Mat -import org.opencv.core.MatOfByte -import org.opencv.imgcodecs.Imgcodecs suspend fun jpegsForExport( imageRepository: ImageRepository, @@ -100,14 +99,3 @@ private fun prepareJpegForHigh( page?.release() } } - -private fun decodeJpeg(jpegBytes: ByteArray): Mat { - val src = MatOfByte(*jpegBytes) - val decoded = Imgcodecs.imdecode(src, Imgcodecs.IMREAD_COLOR) - src.release() - if (decoded.empty()) { - decoded.release() - throw IllegalStateException("Failed to decode JPEG") - } - return decoded -} diff --git a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt b/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt index bc621b3..2adfd23 100644 --- a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt @@ -15,29 +15,27 @@ package org.fairscan.app.platform import org.fairscan.app.data.ImageTransformations +import org.fairscan.imageprocessing.decodeJpeg import org.fairscan.imageprocessing.encodeJpeg import org.opencv.core.Mat import org.opencv.core.Size -import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgproc.Imgproc -import java.io.File import kotlin.math.min class OpenCvTransformations : ImageTransformations { override fun rotate( - inputFile: File, - outputFile: File, + input: ByteArray, rotationDegrees: Int, jpegQuality: Int - ) { - transform(inputFile, outputFile, jpegQuality) { + ): ByteArray { + return transform(input, jpegQuality) { org.fairscan.imageprocessing.rotate(it, rotationDegrees) } } - override fun resize(inputFile: File, outputFile: File, maxSize: Int) { - transform(inputFile, outputFile, 85) { src -> + override fun resize(input: ByteArray, maxSize: Int): ByteArray { + return transform(input, 85) { src -> val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height()) val newW = (src.width() * ratio).toDouble() val newH = (src.height() * ratio).toDouble() @@ -48,18 +46,15 @@ class OpenCvTransformations : ImageTransformations { } private fun transform( - inputFile: File, - outputFile: File, + inBytes: ByteArray, jpegQuality: Int, transform: (Mat) -> Mat, - ) { - val input = Imgcodecs.imread(inputFile.absolutePath) + ): ByteArray { + val input = decodeJpeg(inBytes) var output: Mat? = null try { - require(!input.empty()) { "Could not load image from ${inputFile.absolutePath}" } output = transform.invoke(input) - val outputBytes = encodeJpeg(output, jpegQuality) - outputFile.writeBytes(outputBytes) + return encodeJpeg(output, jpegQuality) } finally { input.release() output?.release() diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index 12aaa6e..12cfabd 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -16,6 +16,7 @@ package org.fairscan.app.data import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.fairscan.app.domain.PageMetadata @@ -38,6 +39,8 @@ class ImageRepositoryTest { private var _filesDir: File? = null + private val testScope = TestScope() + val quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09)) val metadata1 = PageMetadata(quad1, R90, true) @@ -50,14 +53,14 @@ class ImageRepositoryTest { fun repo(): ImageRepository { val transformations = object : ImageTransformations { - override fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) { - inputFile.copyTo(outputFile) + override fun rotate(input: ByteArray, rotationDegrees: Int, jpegQuality: Int): ByteArray { + return input } - override fun resize(inputFile: File, outputFile: File, maxSize: Int) { - outputFile.writeBytes(byteArrayOf(inputFile.readBytes()[0])) + override fun resize(input: ByteArray, maxSize: Int): ByteArray { + return byteArrayOf(input[0]) } } - return ImageRepository(getFilesDir(), transformations, 200) + return ImageRepository(getFilesDir(), transformations, 200, testScope) } @Test @@ -87,12 +90,12 @@ class ImageRepositoryTest { val repo = repo() val bytes = byteArrayOf(101, 102, 103) repo.add(bytes, byteArrayOf(51), metadata1) - assertThat(jpegFiles(scanDir())).hasSize(1) + assertThat(jpegFiles(processedDir())).hasSize(1) assertThat(jpegFiles(sourceDir())).hasSize(1) assertThat(repo.imageIds()).hasSize(1) repo.delete(repo.imageIds()[0]) assertThat(repo.imageIds()).isEmpty() - assertThat(jpegFiles(scanDir())).hasSize(0) + assertThat(jpegFiles(processedDir())).hasSize(0) assertThat(jpegFiles(sourceDir())).hasSize(0) val repo2 = repo() assertThat(repo2.imageIds()).isEmpty() @@ -107,32 +110,32 @@ class ImageRepositoryTest { @Test fun `should find existing files at initialization with no json`() = runTest { - scanDir().mkdirs() - File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) + processedDir().mkdirs() + File(processedDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) assertThat(repo().imageIds()).containsExactly("1") } @Test fun `should find existing files at initialization if json is invalid`() = runTest { writeDocumentDotJson("xxx") - File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) + File(processedDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) assertThat(repo().imageIds()).containsExactly("1") } @Test fun `no json and two files with same id`() = runTest { - scanDir().mkdirs() - File(scanDir(), "1768153479486.jpg").writeBytes(byteArrayOf(101, 102, 103)) - File(scanDir(), "1768153479486-270.jpg").writeBytes(byteArrayOf(105, 106, 107)) + processedDir().mkdirs() + File(processedDir(), "1768153479486.jpg").writeBytes(byteArrayOf(101, 102, 103)) + File(processedDir(), "1768153479486-270.jpg").writeBytes(byteArrayOf(105, 106, 107)) val repo = repo() assertThat(repo.imageIds()).containsExactly("1768153479486") } @Test fun `should find existing files at initialization with no json and with rotation`() = runTest { - scanDir().mkdirs() + processedDir().mkdirs() val bytes = byteArrayOf(101, 102, 103) - File(scanDir(), "1-90.jpg").writeBytes(bytes) + File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes) @@ -141,19 +144,19 @@ class ImageRepositoryTest { @Test fun `should filter pages in json at initialization`() = runTest { writeDocumentDotJson("""{"pages":[{"file":"1.jpg"}, {"file":"2.jpg"}]}""") - File(scanDir(), "2.jpg").writeBytes(byteArrayOf(101, 102, 103)) + File(processedDir(), "2.jpg").writeBytes(byteArrayOf(101, 102, 103)) assertThat(repo().imageIds()).containsExactly("2") } @Test fun `should rename rotated files with no base file`() = runTest { - scanDir().mkdirs() + processedDir().mkdirs() val bytes = byteArrayOf(105, 106, 107) - File(scanDir(), "123-90.jpg").writeBytes(bytes) - File(scanDir(), "123-270.jpg").writeBytes(bytes) + File(processedDir(), "123-90.jpg").writeBytes(bytes) + File(processedDir(), "123-270.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("123") - val jpegFiles = jpegFiles(scanDir()) + val jpegFiles = jpegFiles(processedDir()) assertThat(jpegFiles).hasSize(1).allMatch { it?.name == "123.jpg" } } @@ -161,7 +164,7 @@ class ImageRepositoryTest { fun `should rename rotated files with no base file but listed in json`() = runTest { writeDocumentDotJson("""{"pages":[{"file":"1-90.jpg"}]}""") val bytes = byteArrayOf(105, 106, 107) - File(scanDir(), "1-90.jpg").writeBytes(bytes) + File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes) @@ -182,9 +185,8 @@ class ImageRepositoryTest { assertThat(repo1.imageIds()).isNotEmpty() repo1.clear() assertThat(repo1.imageIds()).isEmpty() - assertThat(jpegFiles(scanDir())).isEmpty() + assertThat(jpegFiles(processedDir())).isEmpty() assertThat(jpegFiles(sourceDir())).isEmpty() - assertThat(jpegFiles(File(getFilesDir(), THUMBNAIL_DIR_NAME))).isEmpty() val repo2 = repo() assertThat(repo2.imageIds()).isEmpty() } @@ -205,6 +207,10 @@ class ImageRepositoryTest { assertThat(repo.pages().last().manualRotation).isEqualTo(R0) repo.rotate(id, false) assertThat(repo.pages().last().manualRotation).isEqualTo(R270) + + val repo2 = repo() + assertThat(repo2.imageIds()).containsExactly(id) + assertThat(repo2.pages().last().manualRotation).isEqualTo(R270) } @Test @@ -255,13 +261,13 @@ class ImageRepositoryTest { val bytes = byteArrayOf(105, 106, 107) writeDocumentDotJson("""{"version":2, "pages":[{"id":"1", "manualRotationDegrees":90}]}""") - File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101)) - File(scanDir(), "1-90.jpg").writeBytes(bytes) + File(processedDir(), "1.jpg").writeBytes(byteArrayOf(101)) + File(processedDir(), "1-90.jpg").writeBytes(bytes) assertThat(repo().imageIds()).containsExactly("1") writeDocumentDotJson("""{"version":2, "pages":[{"id":"1", "manualRotationDegrees":42}]}""") - File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101)) - File(scanDir(), "1-42.jpg").writeBytes(bytes) + File(processedDir(), "1.jpg").writeBytes(byteArrayOf(101)) + File(processedDir(), "1-42.jpg").writeBytes(bytes) assertThat(repo().imageIds()).isEmpty() } @@ -288,15 +294,15 @@ class ImageRepositoryTest { assertThat(repo2.lastAddedSourceFile()).isNull() } - private fun scanDir(): File = File(getFilesDir(), SCAN_DIR_NAME) + private fun processedDir(): File = File(getFilesDir(), PROCESSED_DIR_NAME) private fun sourceDir(): File = File(getFilesDir(), SOURCE_DIR_NAME) private fun jpegFiles(dir: File): Array? = dir.listFiles { f -> f.name.endsWith(".jpg") } private fun writeDocumentDotJson(json: String) { - scanDir().mkdirs() - File(scanDir(), "document.json").writeText(json) + processedDir().mkdirs() + File(processedDir(), "document.json").writeText(json) } suspend fun ImageRepository.imageIds(): PersistentList = diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt index 25dfd9f..f2650dc 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt @@ -50,3 +50,14 @@ fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray { encoded.release() return result } + +fun decodeJpeg(jpegBytes: ByteArray): Mat { + val src = MatOfByte(*jpegBytes) + val decoded = Imgcodecs.imdecode(src, Imgcodecs.IMREAD_COLOR) + src.release() + if (decoded.empty()) { + decoded.release() + throw IllegalStateException("Failed to decode JPEG") + } + return decoded +}