ImageRepository: rotations and thumbnails are not stored on disk anymore
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user