ImageRepository: rotations and thumbnails are not stored on disk anymore

This commit is contained in:
Pierre-Yves Nicolas
2026-03-27 08:46:35 +01:00
parent 69690d5c2a
commit 446b915d59
7 changed files with 179 additions and 181 deletions

View File

@@ -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,
)
}

View File

@@ -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<PageViewKey, Deferred<ByteArray?>>(maxEntries = 50)
private val thumbnailCache = createLruCache<PageViewKey, Deferred<ByteArray?>>(maxEntries = 200)
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
Collections.synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<K, V>) = size > maxEntries
})
// --- Metadata ---
private fun loadPages(): MutableList<PageV2> {
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<PageV2> {
return meta.pages.map { old ->
pageFromLegacyFileName(old.file)
}.toMutableList()
}
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> =
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<ScanPage> = 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<PageViewKey, Deferred<ByteArray?>>,
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<File>
@@ -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() }
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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<out File?>?
= 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<String> =

View File

@@ -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
}