Revamp ImageRepository

This commit is contained in:
Pierre-Yves Nicolas
2026-01-12 16:30:08 +01:00
parent 3b4ba3f027
commit 3c68e08a03
12 changed files with 352 additions and 156 deletions

View File

@@ -19,6 +19,7 @@ import android.graphics.BitmapFactory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -28,6 +29,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel
@@ -39,11 +42,13 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)
private val _pageIds = MutableStateFlow(imageRepository.imageIds())
private val _pages = MutableStateFlow(imageRepository.pages())
val documentUiModel: StateFlow<DocumentUiModel> =
_pageIds.map { ids ->
_pages.map { pages ->
DocumentUiModel(
pageIds = ids,
pageKeys = pages.map { p ->
PageViewKey(p.id, p.metadata?.manualRotation?: Rotation.R0)
}.toImmutableList(),
imageLoader = ::getBitmap,
thumbnailLoader = ::getThumbnail,
)
@@ -64,38 +69,38 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun rotateImage(id: String, clockwise: Boolean) {
viewModelScope.launch {
imageRepository.rotate(id, clockwise)
_pageIds.value = imageRepository.imageIds()
_pages.value = imageRepository.pages()
}
}
fun movePage(id: String, newIndex: Int) {
viewModelScope.launch {
imageRepository.movePage(id, newIndex)
_pageIds.value = imageRepository.imageIds()
_pages.value = imageRepository.pages()
}
}
fun deletePage(id: String) {
viewModelScope.launch {
imageRepository.delete(id)
_pageIds.value = imageRepository.imageIds()
_pages.value = imageRepository.pages()
}
}
fun startNewDocument() {
_pageIds.value = persistentListOf()
_pages.value = persistentListOf()
viewModelScope.launch {
imageRepository.clear()
}
}
fun getBitmap(id: String): Bitmap? {
val bytes = imageRepository.getContent(id)
fun getBitmap(key: PageViewKey): Bitmap? {
val bytes = imageRepository.jpegBytes(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
fun getThumbnail(id: String): Bitmap? {
val bytes = imageRepository.getThumbnail(id)
fun getThumbnail(key: PageViewKey): Bitmap? {
val bytes = imageRepository.getThumbnail(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
@@ -106,7 +111,7 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
compressJpeg(capturedPage.source, 90),
capturedPage.metadata,
)
_pageIds.value = imageRepository.imageIds()
_pages.value = imageRepository.pages()
}
}

View File

@@ -17,16 +17,28 @@ package org.fairscan.app.data
import kotlinx.serialization.Serializable
@Serializable
data class DocumentMetadata(
data class DocumentMetadataV1(
val version: Int = 1,
val pages: List<Page>
val pages: List<PageV1>
)
@Serializable
data class Page(
val file: String,
data class PageV1(
val file: String
)
@Serializable
data class DocumentMetadataV2(
val version: Int = 2,
val pages: List<PageV2>
)
@Serializable
data class PageV2(
val id: String,
val baseRotationDegrees: Int = 0,
val manualRotationDegrees: Int = 0,
val quad: NormalizedQuad? = null,
val rotationDegrees: Int = 0,
val isColored: Boolean? = null
)

View File

@@ -14,10 +14,15 @@
*/
package org.fairscan.app.data
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.domain.ScanPage
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import java.io.File
@@ -46,127 +51,186 @@ class ImageRepository(
private val metadataFile = File(scanDir, "document.json")
private var pages: MutableList<Page> = loadPages()
private var pages: MutableList<PageV2> = loadPages()
private fun loadPages(): MutableList<Page> {
private val json = Json {
prettyPrint = false
encodeDefaults = true
}
private fun loadPages(): MutableList<PageV2> {
val filesOnDisk = scanDir.listFiles()
?.filter { it.extension == "jpg" }
?.map { it.name }
?.toSet()
?: emptySet()
val metadataPages = loadMetadata()?.pages
val metadataPages = if (metadataFile.exists()) {
runCatching { loadMetadata() }.getOrNull()
} else null
return when {
metadataPages != null ->
metadataPages
.filter { it.file in filesOnDisk }
.filter { it.workFileName() in filesOnDisk }
.toMutableList()
else ->
filesOnDisk
.sorted()
.map { Page(file = it) }
.map { pageFromFileName(it) }
.toMutableList()
}
}
private fun indexOfPage(id: String): Int =
pages.indexOfFirst { it.file == id }
pages.indexOfFirst { it.id == id }
private fun loadMetadata(): DocumentMetadata? =
if (metadataFile.exists()) {
runCatching {
Json.decodeFromString<DocumentMetadata>(metadataFile.readText())
}.getOrNull()
} else null
private fun loadMetadata(): List<PageV2> {
val json = metadataFile.readText()
val jsonElement = Json.parseToJsonElement(json)
val version = jsonElement.jsonObject["version"]?.jsonPrimitive?.int ?: 1
return when (version) {
1 -> migrateFromV1(Json.decodeFromJsonElement<DocumentMetadataV1>(jsonElement))
2 -> Json.decodeFromJsonElement<DocumentMetadataV2>(jsonElement).pages
else -> error("Unsupported metadata version: $version")
}
}
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> {
return meta.pages.map {
old -> pageFromFileName(old.file)
}.toMutableList()
}
private fun pageFromFileName(fileName: String): PageV2 {
val fileName = fileName.removeSuffix(".jpg")
val dashIndex = fileName.lastIndexOf('-')
val rotation = if (dashIndex >= 0)
fileName.substring(dashIndex + 1).toInt()
else
0
val id = if (dashIndex >= 0) fileName.substring(0, dashIndex) else fileName
return PageV2(id, manualRotationDegrees = rotation)
}
private fun saveMetadata() {
val metadata = DocumentMetadata(version = 1, pages = pages)
metadataFile.writeText(Json.encodeToString(metadata))
val metadata = DocumentMetadataV2(pages = pages)
metadataFile.writeText(json.encodeToString(metadata))
}
fun imageIds(): PersistentList<String> =
pages.map { it.file }.toPersistentList()
fun getPageMetadata(id: String): PageMetadata? {
val index = indexOfPage(id)
if (index < 0) return null
return pages[index].toMetadata()
fun pages(): List<ScanPage> =
pages.map {
ScanPage(it.id, it.toMetadata())
}
// TODO time complexity should be constant
private fun page(id: String): PageV2? = pages.find { p -> p.id == id }
fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) {
val fileName = "${System.currentTimeMillis()}.jpg"
val id = "${System.currentTimeMillis()}"
val fileName = "$id.jpg"
val file = File(scanDir, fileName)
file.writeBytes(pageBytes)
writeThumbnail(file)
File(sourceDir, fileName).writeBytes(sourceBytes)
pages.add(
Page(
file = fileName,
PageV2(
id = id,
quad = metadata.normalizedQuad.toSerializable(),
rotationDegrees = metadata.rotationDegrees,
baseRotationDegrees = metadata.baseRotation.degrees,
manualRotationDegrees = metadata.manualRotation.degrees,
isColored = metadata.isColored
)
)
saveMetadata()
}
val idRegex = Regex("([0-9]+)(-(90|180|270))?\\.jpg")
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)
fun rotate(id: String, clockwise: Boolean) {
val originalFile = File(scanDir, id)
if (!originalFile.exists()) {
return
}
idRegex.matchEntire(id)?.let {
val baseId = it.groupValues[1]
val degrees = it.groupValues[3].ifEmpty { "0" }.toInt()
val targetDegrees = (degrees + (if (clockwise) 90 else 270)) % 360
val rotatedId = if (targetDegrees == 0) "$baseId.jpg" else "$baseId-$targetDegrees.jpg"
val rotatedFile = File(scanDir, rotatedId)
transformations.rotate(originalFile, rotatedFile, clockwise)
if (rotatedFile.exists()) {
val index = indexOfPage(id)
if (index >= 0) {
val oldPage = pages[index]
pages[index] = oldPage.copy(file = rotatedId)
if (index < 0)
return
val page = pages[index]
val delta = if (clockwise) Rotation.R90 else Rotation.R270
val currentManualRotation = Rotation.fromDegrees(page.manualRotationDegrees)
val newManualRotation = currentManualRotation.add(delta)
if (newManualRotation == currentManualRotation) {
return // no-op
}
val targetFileName = workFileName(page.id, newManualRotation)
val outputFile = File(scanDir, targetFileName)
if (!outputFile.exists()) {
val inputFile = File(scanDir, page.workFileName())
if (!inputFile.exists())
return
transformations.rotate(inputFile, outputFile, clockwise)
}
pages[index] = page.copy(
manualRotationDegrees = newManualRotation.degrees,
)
saveMetadata()
}
delete(id)
}
}
fun jpegBytes(key: PageViewKey): ByteArray? {
val file = File(scanDir, workFileName(key))
return (if (file.exists()) file else null)?.readBytes()
}
fun getContent(id: String): ByteArray? {
return getFileFor(id)?.readBytes()
fun jpegBytes(id: String): ByteArray? {
val page = page(id)
if (page == null) return null
val file = File(scanDir, page.workFileName())
return (if (file.exists()) file else null)?.readBytes()
}
fun getFileFor(id: String): File? {
val file = File(scanDir, id)
return if (file.exists()) file else null
}
fun getSourceFor(id: String): ByteArray? {
val file = File(sourceDir, id)
fun sourceJpegBytes(id: String): ByteArray? {
val file = getSourceFile(id)
return if (file.exists()) file.readBytes() else null
}
fun getThumbnail(id: String): ByteArray? {
val thumbFile = getThumbnailFile(id)
private fun getSourceFile(id: String): File {
return File(sourceDir, "$id.jpg")
}
fun getThumbnail(key: PageViewKey): ByteArray? {
val thumbFile = getThumbnailFile(key)
if (thumbFile == null) {
return null
}
if (!thumbFile.exists()) {
val originalFile = File(scanDir, id)
if (!originalFile.exists()) return null
writeThumbnail(originalFile)
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 = getThumbnailFile(originalFile.name)
val thumbFile = File(thumbnailDir, originalFile.name)
transformations.resize(originalFile, thumbFile, thumbnailSizePx)
}
private fun getThumbnailFile(id: String): File = File(thumbnailDir, id)
private fun getThumbnailFile(key: PageViewKey): File? {
return File(thumbnailDir, workFileName(key))
}
fun movePage(id: String, newIndex: Int) {
val index = indexOfPage(id)
@@ -179,15 +243,24 @@ class ImageRepository(
}
fun delete(id: String) {
File(scanDir, id).delete()
File(sourceDir, id).delete()
getThumbnailFile(id).delete()
pages.removeAll { it.file == id }
val index = indexOfPage(id)
if (index < 0)
return
pages.removeAt(index)
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-") }
?.forEach { it.delete() }
}
fun clear() {
pages.clear()
saveMetadata() // "empty" json file
thumbnailDir.listFiles()?.forEach {
file -> file.delete()
}
@@ -197,7 +270,6 @@ class ImageRepository(
sourceDir.listFiles()?.forEach {
file -> file.delete()
}
saveMetadata() // "empty" json file
}
}
@@ -217,7 +289,14 @@ fun NormalizedQuad.toQuad(): Quad =
Point(bottomLeft.x, bottomLeft.y)
)
fun Page.toMetadata(): PageMetadata? {
fun PageV2.toMetadata(): PageMetadata? {
return runCatching {
if (quad == null || isColored == null) return null
return PageMetadata(quad.toQuad(), rotationDegrees, isColored)
PageMetadata(
quad.toQuad(),
Rotation.fromDegrees(baseRotationDegrees),
Rotation.fromDegrees(manualRotationDegrees),
isColored
)
}.getOrNull()
}

View File

@@ -28,26 +28,27 @@ fun jpegsForExport(
exportQuality: ExportQuality
): Sequence<ByteArray> {
val imageIds = imageRepository.imageIds()
val baseJpegs = imageIds.asSequence().mapNotNull { id -> imageRepository.getContent(id) }
val pages = imageRepository.pages().asSequence()
return when (exportQuality) {
ExportQuality.BALANCED -> baseJpegs
ExportQuality.LOW -> baseJpegs.mapNotNull { jpeg ->
ExportQuality.BALANCED -> pages.mapNotNull { imageRepository.jpegBytes(it.id) }
ExportQuality.LOW -> pages.mapNotNull { page ->
imageRepository.jpegBytes(page.id)?.let { jpeg ->
resizeJpegBytesForMaxPixels(
jpegBytes = jpeg,
maxPixels = exportQuality.maxPixels.toDouble(),
jpegQuality = exportQuality.jpegQuality
)
}
ExportQuality.HIGH -> {
imageIds.asSequence().mapNotNull { id ->
val sourceJpegBytes = imageRepository.getSourceFor(id)
val pageMetadata = imageRepository.getPageMetadata(id)
if (sourceJpegBytes != null && pageMetadata != null)
prepareJpegForHigh(sourceJpegBytes, pageMetadata, ExportQuality.HIGH)
else
imageRepository.getContent(id)
}
ExportQuality.HIGH -> pages.mapNotNull { page ->
val sourceJpegBytes = imageRepository.sourceJpegBytes(page.id)
val pageMetadata = page.metadata
if (sourceJpegBytes != null && pageMetadata != null)
prepareJpegForHigh(sourceJpegBytes, pageMetadata, exportQuality)
else
imageRepository.jpegBytes(page.id)
}
}
}
@@ -83,7 +84,7 @@ fun prepareJpegForHigh(
val page = extractDocument(
decoded,
quad,
pageMetadata.rotationDegrees,
pageMetadata.baseRotation.add(pageMetadata.manualRotation).degrees,
pageMetadata.isColored,
exportQuality.maxPixels)
val outJpegBytes = encodeJpeg(page, exportQuality.jpegQuality)

View File

@@ -18,6 +18,34 @@ import org.fairscan.imageprocessing.Quad
data class PageMetadata(
val normalizedQuad: Quad,
val rotationDegrees: Int,
val baseRotation: Rotation,
val manualRotation: Rotation,
val isColored: Boolean,
)
data class ScanPage(
val id: String,
val metadata: PageMetadata?,
)
data class PageViewKey(
val pageId: String,
val rotation: Rotation,
) {
val saveKey: String get() = "$pageId-${rotation.degrees}"
}
enum class Rotation(val degrees: Int) {
R0(0),
R90(90),
R180(180),
R270(270);
fun add(other: Rotation): Rotation =
fromDegrees((degrees + other.degrees) % 360)
companion object {
fun fromDegrees(deg: Int): Rotation =
entries.first { it.degrees == ((deg % 360 + 360) % 360) }
}
}

View File

@@ -18,6 +18,9 @@ import android.content.Context
import android.graphics.BitmapFactory
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.state.DocumentUiModel
fun dummyNavigation(): Navigation {
@@ -29,10 +32,11 @@ fun fakeDocument(): DocumentUiModel {
}
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
val loader = { id:String ->
context.assets.open(id).use { input ->
val loader = { key: PageViewKey ->
context.assets.open("${key.pageId}.jpg").use { input ->
BitmapFactory.decodeStream(input)
}
}
return DocumentUiModel(pageIds, loader, loader)
val pageKeys = pageIds.map { PageViewKey(it, Rotation.R0) }.toImmutableList()
return DocumentUiModel(pageKeys, loader, loader)
}

View File

@@ -75,8 +75,8 @@ fun CommonPageList(
state.onPageReorder(from.key as String, to.index)
}
val content: LazyListScope.() -> Unit = {
itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item ->
ReorderableItem(reorderableLazyListState, key = item) { isDragging ->
itemsIndexed(state.document.pageKeys, key = { _, item -> item.saveKey}) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.saveKey) { isDragging ->
val borderColor =
if (isDragging) MaterialTheme.colorScheme.primary else Color.Transparent
val modifier = Modifier

View File

@@ -86,6 +86,7 @@ import org.fairscan.app.MainViewModel
import org.fairscan.app.R
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.CameraPermissionState
@@ -471,7 +472,7 @@ fun CameraScreenPreviewWithProcessedImage() {
CapturedPage(
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
PageMetadata(quad, 0, false))))
PageMetadata(quad, R0, R0, false))))
}
@Preview(showBackground = true, widthDp = 640, heightDp = 320)

View File

@@ -34,6 +34,7 @@ import org.fairscan.app.AppContainer
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation
import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.detectDocumentQuad
@@ -206,7 +207,8 @@ fun extractDocumentFromBitmap(
val outBitmap = toBitmap(outBgr)
outBgr.release()
val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
val metadata = PageMetadata(normalizedQuad, rotationDegrees, isColored)
val baseRotation = Rotation.fromDegrees(rotationDegrees)
val metadata = PageMetadata(normalizedQuad, baseRotation, Rotation.R0, isColored)
return CapturedPage(outBitmap, source, metadata)
}

View File

@@ -16,28 +16,29 @@ package org.fairscan.app.ui.state
import android.graphics.Bitmap
import kotlinx.collections.immutable.ImmutableList
import org.fairscan.app.domain.PageViewKey
data class DocumentUiModel(
val pageIds: ImmutableList<String>,
private val imageLoader: (String) -> Bitmap?,
private val thumbnailLoader: (String) -> Bitmap?
val pageKeys: ImmutableList<PageViewKey>,
private val imageLoader: (PageViewKey) -> Bitmap?,
private val thumbnailLoader: (PageViewKey) -> Bitmap?
) {
fun pageCount(): Int {
return pageIds.size
return pageKeys.size
}
fun pageId(index: Int): String {
return pageIds[index]
return pageKeys[index].pageId
}
fun isEmpty(): Boolean {
return pageIds.isEmpty()
return pageKeys.isEmpty()
}
fun lastIndex(): Int {
return pageIds.lastIndex
return pageKeys.lastIndex
}
fun load(index: Int): Bitmap? {
return imageLoader(pageIds[index])
return imageLoader(pageKeys[index])
}
fun loadThumbnail(index: Int): Bitmap? {
return thumbnailLoader(pageIds[index])
return thumbnailLoader(pageKeys[index])
}
}

View File

@@ -14,8 +14,15 @@
*/
package org.fairscan.app.data
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.domain.Rotation.R180
import org.fairscan.app.domain.Rotation.R270
import org.fairscan.app.domain.Rotation.R90
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.junit.Rule
@@ -31,7 +38,7 @@ class ImageRepositoryTest {
private var _filesDir: File? = null
val quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09))
val metadata1 = PageMetadata(quad1, 90, true)
val metadata1 = PageMetadata(quad1, R90, R0, true)
fun getFilesDir(): File {
if (_filesDir == null) {
@@ -60,14 +67,17 @@ class ImageRepositoryTest {
repo.add(bytes, byteArrayOf(51), metadata1)
assertThat(repo.imageIds()).hasSize(1)
val id = repo.imageIds()[0]
assertThat(repo.getContent(id)).isEqualTo(bytes)
assertThat(repo.getThumbnail(id)).isEqualTo(byteArrayOf(101))
val key = PageViewKey(id, R0)
assertThat(repo.jpegBytes(key)).isEqualTo(bytes)
assertThat(repo.getThumbnail(key)).isEqualTo(byteArrayOf(101))
assertThat(repo().getPageMetadata("x")).isNull()
val metadata = repo.getPageMetadata(id)
val page = repo.pages().first()
assertThat(page.id).isEqualTo(id)
val metadata = page.metadata
assertThat(metadata).isNotNull()
assertThat(metadata!!.normalizedQuad).isEqualTo(quad1)
assertThat(metadata.rotationDegrees).isEqualTo(metadata1.rotationDegrees)
assertThat(metadata.baseRotation).isEqualTo(metadata1.baseRotation)
assertThat(metadata.manualRotation).isEqualTo(metadata1.manualRotation)
assertThat(metadata.isColored).isEqualTo(metadata1.isColored)
}
@@ -98,7 +108,17 @@ class ImageRepositoryTest {
fun `should find existing files at initialization with no json`() {
scanDir().mkdirs()
File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103))
assertThat(repo().imageIds()).containsExactly("1.jpg")
assertThat(repo().imageIds()).containsExactly("1")
}
@Test
fun `should find existing files at initialization with no json and with rotation`() {
scanDir().mkdirs()
val bytes = byteArrayOf(101, 102, 103)
File(scanDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo()
assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes("1")).isEqualTo(bytes)
}
@Test
@@ -107,14 +127,14 @@ class ImageRepositoryTest {
val json = """{"pages":[{"file":"1.jpg"}, {"file":"2.jpg"}]}"""
File(scanDir(), "document.json").writeText(json)
File(scanDir(), "2.jpg").writeBytes(byteArrayOf(101, 102, 103))
assertThat(repo().imageIds()).containsExactly("2.jpg")
assertThat(repo().imageIds()).containsExactly("2")
}
@Test
fun `should return null on invalid id`() {
val repo = repo()
assertThat(repo.imageIds()).isEmpty()
assertThat(repo.getContent("x")).isNull()
assertThat(repo.jpegBytes("x")).isNull()
}
@Test
@@ -136,28 +156,19 @@ class ImageRepositoryTest {
fun rotate() {
val repo = repo()
repo.add(byteArrayOf(101, 102, 103), byteArrayOf(51), metadata1)
val id0 = repo.imageIds().last()
val baseId = id0.substring(0, id0.length - 4)
repo.rotate(id0, true)
val id1 = repo.imageIds().last()
assertThat(id1).isEqualTo("$baseId-90.jpg")
repo.rotate(id1, true)
val id2 = repo.imageIds().last()
assertThat(id2).isEqualTo("$baseId-180.jpg")
repo.rotate(id2, true)
val id3 = repo.imageIds().last()
assertThat(id3).isEqualTo("$baseId-270.jpg")
repo.rotate(id3, true)
val id4 = repo.imageIds().last()
assertThat(id4).isEqualTo("$baseId.jpg")
repo.rotate(id4, false)
val id5 = repo.imageIds().last()
assertThat(id5).isEqualTo("$baseId-270.jpg")
assertThat(metadata1.manualRotation).isEqualTo(R0)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1)
val id = repo.pages().last().id
repo.rotate(id, true)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R90))
repo.rotate(id, true)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R180))
repo.rotate(id, true)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R270))
repo.rotate(id, true)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R0))
repo.rotate(id, false)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R270))
}
@Test
@@ -179,14 +190,16 @@ class ImageRepositoryTest {
fun metadata() {
val quad = quad1.toSerializable()
assertThat(Page("f1", null, 0, true).toMetadata()).isNull()
assertThat(Page("f1", quad, 0, null).toMetadata()).isNull()
assertThat(PageV2("1", 0, 0, null,true).toMetadata()).isNull()
assertThat(PageV2("1", 0, 0, quad, null).toMetadata()).isNull()
listOf(true, false).forEach { isColored ->
val metadata = Page("f1", quad, 0, isColored).toMetadata()
val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata()
assertThat(metadata).isNotNull()
assertThat(metadata!!.isColored).isEqualTo(isColored)
}
assertThat(PageV2("1", 42, 0, quad, true).toMetadata()).isNull()
}
private fun scanDir(): File = File(getFilesDir(), SCAN_DIR_NAME)
@@ -194,4 +207,7 @@ class ImageRepositoryTest {
private fun jpegFiles(dir: File): Array<out File?>?
= dir.listFiles { f -> f.name.endsWith(".jpg") }
fun ImageRepository.imageIds(): PersistentList<String> =
pages().map { it.id }.toPersistentList()
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.domain
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.fairscan.app.domain.Rotation.Companion.fromDegrees
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.domain.Rotation.R180
import org.fairscan.app.domain.Rotation.R270
import org.fairscan.app.domain.Rotation.R90
import org.junit.Test
class RotationTest {
@Test
fun fromDegreesFunction() {
assertThat(fromDegrees(0)).isEqualTo(R0)
assertThat(fromDegrees(90)).isEqualTo(R90)
assertThat(fromDegrees(180)).isEqualTo(R180)
assertThat(fromDegrees(270)).isEqualTo(R270)
assertThat(fromDegrees(360)).isEqualTo(R0)
assertThat(fromDegrees(-90)).isEqualTo(R270)
assertThatThrownBy { fromDegrees(30) }.isInstanceOf(NoSuchElementException::class.java)
}
@Test
fun add() {
assertThat(R0.add(R90)).isEqualTo(R90)
assertThat(R90.add(R90)).isEqualTo(R180)
assertThat(R90.add(R180)).isEqualTo(R270)
assertThat(R180.add(R180)).isEqualTo(R0)
assertThat(R180.add(R270)).isEqualTo(R90)
}
}