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.app.Application
import android.content.Context import android.content.Context
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.app.platform.OpenCvTransformations import org.fairscan.app.platform.OpenCvTransformations
import java.io.File import java.io.File
@@ -39,7 +41,8 @@ class SessionViewModel(
private val sessionContainer = ScanSessionContainer( private val sessionContainer = ScanSessionContainer(
context = app, context = app,
scanRootDir = sessionDir scanRootDir = sessionDir,
scope = viewModelScope,
) )
val imageRepository: ImageRepository = sessionContainer.imageRepository val imageRepository: ImageRepository = sessionContainer.imageRepository
@@ -55,7 +58,8 @@ class SessionViewModel(
class ScanSessionContainer( class ScanSessionContainer(
context: Context, context: Context,
scanRootDir: File scanRootDir: File,
scope: CoroutineScope,
) { ) {
private val density = context.resources.displayMetrics.density private val density = context.resources.displayMetrics.density
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt() private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
@@ -63,6 +67,7 @@ class ScanSessionContainer(
val imageRepository = ImageRepository( val imageRepository = ImageRepository(
scanRootDir, scanRootDir,
OpenCvTransformations(), OpenCvTransformations(),
thumbnailSizePx thumbnailSizePx,
scope,
) )
} }

View File

@@ -14,8 +14,15 @@
*/ */
package org.fairscan.app.data 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
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
@@ -29,9 +36,10 @@ import org.fairscan.app.domain.ScanPage
import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import java.io.File import java.io.File
import java.util.Collections
const val SOURCE_DIR_NAME = "sources" 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" const val THUMBNAIL_DIR_NAME = "thumbnails"
/** /**
@@ -44,34 +52,32 @@ class ImageRepository(
scanRootDir: File, scanRootDir: File,
val transformations: ImageTransformations, val transformations: ImageTransformations,
private val thumbnailSizePx: Int, private val thumbnailSizePx: Int,
private val scope: CoroutineScope,
) { ) {
private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() }
private val sourceDir: File = File(scanRootDir, SOURCE_DIR_NAME).apply { private val processedDir = File(scanRootDir, PROCESSED_DIR_NAME).apply { mkdirs() }
if (!exists()) mkdirs() private val thumbnailDir = File(scanRootDir, THUMBNAIL_DIR_NAME)
}
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 mutex = Mutex() private val mutex = Mutex()
private val metadataFile = File(scanDir, "document.json") private val metadataFile = File(processedDir, "document.json")
private val json = Json { prettyPrint = false; encodeDefaults = true }
private val json = Json {
prettyPrint = false
encodeDefaults = true
}
private var pages: PageStore = PageStore(loadPages()) 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> { private fun loadPages(): MutableList<PageV2> {
thumbnailDir.deleteRecursively() // clean up dir that was used in older versions
normalizeLegacyFiles() normalizeLegacyFiles()
val filesOnDisk = scanDir.listFiles() val filesOnDisk = processedDir.listFiles()
?.filter { it.extension == "jpg" } ?.filter { it.extension == "jpg" }
?.map { it.name } ?.map { it.name }
?.toSet() ?.toSet()
@@ -84,7 +90,7 @@ class ImageRepository(
return when { return when {
metadataPages != null -> metadataPages != null ->
metadataPages metadataPages
.filter { it.workFileName() in filesOnDisk } .filter { "${it.id}.jpg" in filesOnDisk }
.toMutableList() .toMutableList()
else -> else ->
filesOnDisk filesOnDisk
@@ -107,11 +113,8 @@ class ImageRepository(
} }
} }
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> { private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> =
return meta.pages.map { old -> meta.pages.map { pageFromLegacyFileName(it.file) }.toMutableList()
pageFromLegacyFileName(old.file)
}.toMutableList()
}
private fun pageFromLegacyFileName(fileName: String): PageV2 { private fun pageFromLegacyFileName(fileName: String): PageV2 {
val name = fileName.removeSuffix(".jpg") val name = fileName.removeSuffix(".jpg")
@@ -125,6 +128,8 @@ class ImageRepository(
metadataFile.writeText(json.encodeToString(metadata)) metadataFile.writeText(json.encodeToString(metadata))
} }
// --- Main API ---
suspend fun pages(): List<ScanPage> = mutex.withLock { suspend fun pages(): List<ScanPage> = mutex.withLock {
pages.pages().mapNotNull { pages.pages().mapNotNull {
runCatching { runCatching {
@@ -134,94 +139,91 @@ class ImageRepository(
} }
} }
suspend fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) suspend fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) =
= mutex.withLock { mutex.withLock {
val id = "${System.currentTimeMillis()}" val id = "${System.currentTimeMillis()}"
val fileName = "$id.jpg" val fileName = "$id.jpg"
val file = File(scanDir, fileName) File(processedDir, fileName).writeBytes(pageBytes)
file.writeBytes(pageBytes) File(sourceDir, fileName).writeBytes(sourceBytes)
writeThumbnail(file) pages.addOrReplace(
File(sourceDir, fileName).writeBytes(sourceBytes) PageV2(
pages.addOrReplace( id = id,
PageV2( quad = metadata.normalizedQuad.toSerializable(),
id = id, baseRotationDegrees = metadata.baseRotation.degrees,
quad = metadata.normalizedQuad.toSerializable(), manualRotationDegrees = Rotation.R0.degrees,
baseRotationDegrees = metadata.baseRotation.degrees, isColored = metadata.isColored
manualRotationDegrees = Rotation.R0.degrees, )
isColored = metadata.isColored
) )
) saveMetadata()
saveMetadata() // Pre-populate cache for R0
} val key = PageViewKey(id, Rotation.R0)
imageCache.put(key, CompletableDeferred(pageBytes))
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"
} }
fun PageV2.workFileName() = workFileName(id, manualRotationDegrees)
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock { suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
val page = pages.get(id) ?: return@withLock 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)
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) { pages.update(id) {
it.copy(manualRotationDegrees = newRotation.degrees) it.copy(manualRotationDegrees = newRotation.degrees)
} }
saveMetadata() saveMetadata()
} }
fun jpegBytes(key: PageViewKey): ByteArray? { fun jpegBytes(key: PageViewKey): ByteArray? = runBlocking(Dispatchers.IO) {
val file = File(scanDir, workFileName(key)) getOrCompute(imageCache, key, ::computeProcessedImage)
return (if (file.exists()) file else null)?.readBytes()
} }
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? { fun sourceJpegBytes(id: String): ByteArray? {
val file = getSourceFile(id) val file = File(sourceDir, "$id.jpg")
return if (file.exists()) file.readBytes() else null 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 { suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {
pages.move(id, newIndex) pages.move(id, newIndex)
saveMetadata() saveMetadata()
@@ -230,31 +232,24 @@ class ImageRepository(
suspend fun delete(id: String) = mutex.withLock { suspend fun delete(id: String) = mutex.withLock {
pages.delete(id) pages.delete(id)
saveMetadata() saveMetadata()
File(sourceDir, "$id.jpg").delete()
getSourceFile(id).delete() processedDir.listFiles()
scanDir.listFiles() ?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") }
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
?.forEach { it.delete() }
thumbnailDir.listFiles()
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
?.forEach { it.delete() } ?.forEach { it.delete() }
// No need to clean caches: stale entries will be evicted by LRU
} }
suspend fun clear() = mutex.withLock { suspend fun clear() = mutex.withLock {
pages.clear() pages.clear()
saveMetadata() // "empty" json file saveMetadata()
sourceDir.listFiles()?.forEach { it.delete() }
thumbnailDir.listFiles()?.forEach { processedDir.listFiles()?.forEach { it.delete() }
file -> file.delete() synchronized(imageCache) { imageCache.clear() }
} synchronized(thumbnailCache) { thumbnailCache.clear() }
scanDir.listFiles()?.forEach {
file -> file.delete()
}
sourceDir.listFiles()?.forEach {
file -> file.delete()
}
} }
// --- Legacy migration ---
data class DiskPageFiles( data class DiskPageFiles(
val base: File?, val base: File?,
val rotated: List<File> val rotated: List<File>
@@ -265,7 +260,7 @@ class ImageRepository(
// and discard the others. We intentionally sacrifice exact rotation // and discard the others. We intentionally sacrifice exact rotation
// fidelity to restore a coherent model. // fidelity to restore a coherent model.
private fun normalizeLegacyFiles() { 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 byId = jpgs.groupBy { file ->
val name = file.name.removeSuffix(".jpg") val name = file.name.removeSuffix(".jpg")
val dash = name.lastIndexOf('-') val dash = name.lastIndexOf('-')
@@ -280,7 +275,7 @@ class ImageRepository(
if (files.base == null && files.rotated.isNotEmpty()) { if (files.base == null && files.rotated.isNotEmpty()) {
val sortedRotatedFiles = files.rotated.sortedBy { it.name } val sortedRotatedFiles = files.rotated.sortedBy { it.name }
val legacyFile = sortedRotatedFiles.first() val legacyFile = sortedRotatedFiles.first()
val target = File(scanDir, "$id.jpg") val target = File(processedDir, "$id.jpg")
if (legacyFile.renameTo(target)) { if (legacyFile.renameTo(target)) {
sortedRotatedFiles.drop(1).forEach { it.delete() } sortedRotatedFiles.drop(1).forEach { it.delete() }
} }

View File

@@ -14,12 +14,10 @@
*/ */
package org.fairscan.app.data package org.fairscan.app.data
import java.io.File
interface ImageTransformations { 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 package org.fairscan.app.domain
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.imageprocessing.decodeJpeg
import org.fairscan.imageprocessing.encodeJpeg import org.fairscan.imageprocessing.encodeJpeg
import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.resizeForMaxPixels 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.imgcodecs.Imgcodecs
suspend fun jpegsForExport( suspend fun jpegsForExport(
imageRepository: ImageRepository, imageRepository: ImageRepository,
@@ -100,14 +99,3 @@ private fun prepareJpegForHigh(
page?.release() 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 package org.fairscan.app.platform
import org.fairscan.app.data.ImageTransformations import org.fairscan.app.data.ImageTransformations
import org.fairscan.imageprocessing.decodeJpeg
import org.fairscan.imageprocessing.encodeJpeg import org.fairscan.imageprocessing.encodeJpeg
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.Size import org.opencv.core.Size
import org.opencv.imgcodecs.Imgcodecs
import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc
import java.io.File
import kotlin.math.min import kotlin.math.min
class OpenCvTransformations : ImageTransformations { class OpenCvTransformations : ImageTransformations {
override fun rotate( override fun rotate(
inputFile: File, input: ByteArray,
outputFile: File,
rotationDegrees: Int, rotationDegrees: Int,
jpegQuality: Int jpegQuality: Int
) { ): ByteArray {
transform(inputFile, outputFile, jpegQuality) { return transform(input, jpegQuality) {
org.fairscan.imageprocessing.rotate(it, rotationDegrees) org.fairscan.imageprocessing.rotate(it, rotationDegrees)
} }
} }
override fun resize(inputFile: File, outputFile: File, maxSize: Int) { override fun resize(input: ByteArray, maxSize: Int): ByteArray {
transform(inputFile, outputFile, 85) { src -> return transform(input, 85) { src ->
val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height()) val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height())
val newW = (src.width() * ratio).toDouble() val newW = (src.width() * ratio).toDouble()
val newH = (src.height() * ratio).toDouble() val newH = (src.height() * ratio).toDouble()
@@ -48,18 +46,15 @@ class OpenCvTransformations : ImageTransformations {
} }
private fun transform( private fun transform(
inputFile: File, inBytes: ByteArray,
outputFile: File,
jpegQuality: Int, jpegQuality: Int,
transform: (Mat) -> Mat, transform: (Mat) -> Mat,
) { ): ByteArray {
val input = Imgcodecs.imread(inputFile.absolutePath) val input = decodeJpeg(inBytes)
var output: Mat? = null var output: Mat? = null
try { try {
require(!input.empty()) { "Could not load image from ${inputFile.absolutePath}" }
output = transform.invoke(input) output = transform.invoke(input)
val outputBytes = encodeJpeg(output, jpegQuality) return encodeJpeg(output, jpegQuality)
outputFile.writeBytes(outputBytes)
} finally { } finally {
input.release() input.release()
output?.release() output?.release()

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.TestScope
import kotlinx.coroutines.test.runTest 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
@@ -38,6 +39,8 @@ class ImageRepositoryTest {
private var _filesDir: File? = null 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 quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09))
val metadata1 = PageMetadata(quad1, R90, true) val metadata1 = PageMetadata(quad1, R90, true)
@@ -50,14 +53,14 @@ class ImageRepositoryTest {
fun repo(): ImageRepository { fun repo(): ImageRepository {
val transformations = object : ImageTransformations { val transformations = object : ImageTransformations {
override fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) { override fun rotate(input: ByteArray, rotationDegrees: Int, jpegQuality: Int): ByteArray {
inputFile.copyTo(outputFile) return input
} }
override fun resize(inputFile: File, outputFile: File, maxSize: Int) { override fun resize(input: ByteArray, maxSize: Int): ByteArray {
outputFile.writeBytes(byteArrayOf(inputFile.readBytes()[0])) return byteArrayOf(input[0])
} }
} }
return ImageRepository(getFilesDir(), transformations, 200) return ImageRepository(getFilesDir(), transformations, 200, testScope)
} }
@Test @Test
@@ -87,12 +90,12 @@ class ImageRepositoryTest {
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)
assertThat(jpegFiles(scanDir())).hasSize(1) assertThat(jpegFiles(processedDir())).hasSize(1)
assertThat(jpegFiles(sourceDir())).hasSize(1) assertThat(jpegFiles(sourceDir())).hasSize(1)
assertThat(repo.imageIds()).hasSize(1) assertThat(repo.imageIds()).hasSize(1)
repo.delete(repo.imageIds()[0]) repo.delete(repo.imageIds()[0])
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
assertThat(jpegFiles(scanDir())).hasSize(0) assertThat(jpegFiles(processedDir())).hasSize(0)
assertThat(jpegFiles(sourceDir())).hasSize(0) assertThat(jpegFiles(sourceDir())).hasSize(0)
val repo2 = repo() val repo2 = repo()
assertThat(repo2.imageIds()).isEmpty() assertThat(repo2.imageIds()).isEmpty()
@@ -107,32 +110,32 @@ class ImageRepositoryTest {
@Test @Test
fun `should find existing files at initialization with no json`() = runTest { fun `should find existing files at initialization with no json`() = runTest {
scanDir().mkdirs() processedDir().mkdirs()
File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(processedDir(), "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`() = runTest { 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(processedDir(), "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`() = runTest { fun `no json and two files with same id`() = runTest {
scanDir().mkdirs() processedDir().mkdirs()
File(scanDir(), "1768153479486.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(processedDir(), "1768153479486.jpg").writeBytes(byteArrayOf(101, 102, 103))
File(scanDir(), "1768153479486-270.jpg").writeBytes(byteArrayOf(105, 106, 107)) File(processedDir(), "1768153479486-270.jpg").writeBytes(byteArrayOf(105, 106, 107))
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1768153479486") assertThat(repo.imageIds()).containsExactly("1768153479486")
} }
@Test @Test
fun `should find existing files at initialization with no json and with rotation`() = runTest { fun `should find existing files at initialization with no json and with rotation`() = runTest {
scanDir().mkdirs() processedDir().mkdirs()
val bytes = byteArrayOf(101, 102, 103) val bytes = byteArrayOf(101, 102, 103)
File(scanDir(), "1-90.jpg").writeBytes(bytes) File(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes)
@@ -141,19 +144,19 @@ class ImageRepositoryTest {
@Test @Test
fun `should filter pages in json at initialization`() = runTest { 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(processedDir(), "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`() = runTest { fun `should rename rotated files with no base file`() = runTest {
scanDir().mkdirs() processedDir().mkdirs()
val bytes = byteArrayOf(105, 106, 107) val bytes = byteArrayOf(105, 106, 107)
File(scanDir(), "123-90.jpg").writeBytes(bytes) File(processedDir(), "123-90.jpg").writeBytes(bytes)
File(scanDir(), "123-270.jpg").writeBytes(bytes) File(processedDir(), "123-270.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("123") assertThat(repo.imageIds()).containsExactly("123")
val jpegFiles = jpegFiles(scanDir()) val jpegFiles = jpegFiles(processedDir())
assertThat(jpegFiles).hasSize(1).allMatch { it?.name == "123.jpg" } 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 { 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(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes)
@@ -182,9 +185,8 @@ class ImageRepositoryTest {
assertThat(repo1.imageIds()).isNotEmpty() assertThat(repo1.imageIds()).isNotEmpty()
repo1.clear() repo1.clear()
assertThat(repo1.imageIds()).isEmpty() assertThat(repo1.imageIds()).isEmpty()
assertThat(jpegFiles(scanDir())).isEmpty() assertThat(jpegFiles(processedDir())).isEmpty()
assertThat(jpegFiles(sourceDir())).isEmpty() assertThat(jpegFiles(sourceDir())).isEmpty()
assertThat(jpegFiles(File(getFilesDir(), THUMBNAIL_DIR_NAME))).isEmpty()
val repo2 = repo() val repo2 = repo()
assertThat(repo2.imageIds()).isEmpty() assertThat(repo2.imageIds()).isEmpty()
} }
@@ -205,6 +207,10 @@ class ImageRepositoryTest {
assertThat(repo.pages().last().manualRotation).isEqualTo(R0) assertThat(repo.pages().last().manualRotation).isEqualTo(R0)
repo.rotate(id, false) repo.rotate(id, false)
assertThat(repo.pages().last().manualRotation).isEqualTo(R270) assertThat(repo.pages().last().manualRotation).isEqualTo(R270)
val repo2 = repo()
assertThat(repo2.imageIds()).containsExactly(id)
assertThat(repo2.pages().last().manualRotation).isEqualTo(R270)
} }
@Test @Test
@@ -255,13 +261,13 @@ class ImageRepositoryTest {
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}]}""")
File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101)) File(processedDir(), "1.jpg").writeBytes(byteArrayOf(101))
File(scanDir(), "1-90.jpg").writeBytes(bytes) File(processedDir(), "1-90.jpg").writeBytes(bytes)
assertThat(repo().imageIds()).containsExactly("1") assertThat(repo().imageIds()).containsExactly("1")
writeDocumentDotJson("""{"version":2, "pages":[{"id":"1", "manualRotationDegrees":42}]}""") writeDocumentDotJson("""{"version":2, "pages":[{"id":"1", "manualRotationDegrees":42}]}""")
File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101)) File(processedDir(), "1.jpg").writeBytes(byteArrayOf(101))
File(scanDir(), "1-42.jpg").writeBytes(bytes) File(processedDir(), "1-42.jpg").writeBytes(bytes)
assertThat(repo().imageIds()).isEmpty() assertThat(repo().imageIds()).isEmpty()
} }
@@ -288,15 +294,15 @@ class ImageRepositoryTest {
assertThat(repo2.lastAddedSourceFile()).isNull() 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 sourceDir(): File = File(getFilesDir(), SOURCE_DIR_NAME)
private fun jpegFiles(dir: File): Array<out File?>? private fun jpegFiles(dir: File): Array<out File?>?
= dir.listFiles { f -> f.name.endsWith(".jpg") } = dir.listFiles { f -> f.name.endsWith(".jpg") }
private fun writeDocumentDotJson(json: String) { private fun writeDocumentDotJson(json: String) {
scanDir().mkdirs() processedDir().mkdirs()
File(scanDir(), "document.json").writeText(json) File(processedDir(), "document.json").writeText(json)
} }
suspend fun ImageRepository.imageIds(): PersistentList<String> = suspend fun ImageRepository.imageIds(): PersistentList<String> =

View File

@@ -50,3 +50,14 @@ fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray {
encoded.release() encoded.release()
return result 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
}