From 3c68e08a031abe3b1551fb429de0ba710c903852 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:30:08 +0100 Subject: [PATCH] Revamp ImageRepository --- .../java/org/fairscan/app/MainViewModel.kt | 29 ++- .../org/fairscan/app/data/DocumentMetadata.kt | 22 +- .../org/fairscan/app/data/ImageRepository.kt | 217 ++++++++++++------ .../fairscan/app/domain/ExportPreparation.kt | 39 ++-- .../app/domain/{PageMetadata.kt => Page.kt} | 30 ++- .../java/org/fairscan/app/ui/PreviewUtils.kt | 10 +- .../fairscan/app/ui/components/PageList.kt | 4 +- .../app/ui/screens/camera/CameraScreen.kt | 3 +- .../app/ui/screens/camera/CameraViewModel.kt | 4 +- .../fairscan/app/ui/state/DocumentUiModel.kt | 19 +- .../fairscan/app/data/ImageRepositoryTest.kt | 84 ++++--- .../org/fairscan/app/domain/RotationTest.kt | 47 ++++ 12 files changed, 352 insertions(+), 156 deletions(-) rename app/src/main/java/org/fairscan/app/domain/{PageMetadata.kt => Page.kt} (57%) create mode 100644 app/src/test/java/org/fairscan/app/domain/RotationTest.kt diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 4bd695c..b8e7e93 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -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 = _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 = - _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() } } diff --git a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt index 015bbc8..03db521 100644 --- a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt +++ b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt @@ -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 + val pages: List ) @Serializable -data class Page( - val file: String, +data class PageV1( + val file: String +) + +@Serializable +data class DocumentMetadataV2( + val version: Int = 2, + val pages: List +) + +@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 ) diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index a5bfedf..a738080 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -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 = loadPages() + private var pages: MutableList = loadPages() - private fun loadPages(): MutableList { + private val json = Json { + prettyPrint = false + encodeDefaults = true + } + + private fun loadPages(): MutableList { 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(metadataFile.readText()) - }.getOrNull() - } else null + private fun loadMetadata(): List { + val json = metadataFile.readText() + + val jsonElement = Json.parseToJsonElement(json) + val version = jsonElement.jsonObject["version"]?.jsonPrimitive?.int ?: 1 + + return when (version) { + 1 -> migrateFromV1(Json.decodeFromJsonElement(jsonElement)) + 2 -> Json.decodeFromJsonElement(jsonElement).pages + else -> error("Unsupported metadata version: $version") + } + } + + private fun migrateFromV1(meta: DocumentMetadataV1): MutableList { + 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 = - pages.map { it.file }.toPersistentList() + fun pages(): List = + pages.map { + ScanPage(it.id, it.toMetadata()) + } - fun getPageMetadata(id: String): PageMetadata? { - val index = indexOfPage(id) - if (index < 0) return null - return pages[index].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()) { + val index = indexOfPage(id) + 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 } - 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) - saveMetadata() - } - delete(id) - } + + 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() } - fun getContent(id: String): ByteArray? { - return getFileFor(id)?.readBytes() + fun jpegBytes(key: PageViewKey): ByteArray? { + val file = File(scanDir, workFileName(key)) + 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 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 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 } } @@ -215,9 +287,16 @@ fun NormalizedQuad.toQuad(): Quad = Point(topRight.x, topRight.y), Point(bottomRight.x, bottomRight.y), Point(bottomLeft.x, bottomLeft.y) -) + ) -fun Page.toMetadata(): PageMetadata? { - if (quad == null || isColored == null) return null - return PageMetadata(quad.toQuad(), rotationDegrees, isColored) +fun PageV2.toMetadata(): PageMetadata? { + return runCatching { + if (quad == null || isColored == null) return null + PageMetadata( + quad.toQuad(), + Rotation.fromDegrees(baseRotationDegrees), + Rotation.fromDegrees(manualRotationDegrees), + isColored + ) + }.getOrNull() } diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index ac49821..2671a52 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -28,27 +28,28 @@ fun jpegsForExport( exportQuality: ExportQuality ): Sequence { - 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 -> - 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.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 -> 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) diff --git a/app/src/main/java/org/fairscan/app/domain/PageMetadata.kt b/app/src/main/java/org/fairscan/app/domain/Page.kt similarity index 57% rename from app/src/main/java/org/fairscan/app/domain/PageMetadata.kt rename to app/src/main/java/org/fairscan/app/domain/Page.kt index a60b7f8..849b31f 100644 --- a/app/src/main/java/org/fairscan/app/domain/PageMetadata.kt +++ b/app/src/main/java/org/fairscan/app/domain/Page.kt @@ -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) } + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt index f073437..79ab054 100644 --- a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt @@ -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, 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) } diff --git a/app/src/main/java/org/fairscan/app/ui/components/PageList.kt b/app/src/main/java/org/fairscan/app/ui/components/PageList.kt index 4bf70f7..1d0bdbf 100644 --- a/app/src/main/java/org/fairscan/app/ui/components/PageList.kt +++ b/app/src/main/java/org/fairscan/app/ui/components/PageList.kt @@ -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 diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index dbe1198..c965f57 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -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) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt index 41489ba..1f691b6 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt @@ -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) } diff --git a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt index f56e8cb..043babb 100644 --- a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt @@ -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, - private val imageLoader: (String) -> Bitmap?, - private val thumbnailLoader: (String) -> Bitmap? + val pageKeys: ImmutableList, + 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]) } } \ No newline at end of file diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index 26accaa..c9c175f 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -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? = dir.listFiles { f -> f.name.endsWith(".jpg") } + + fun ImageRepository.imageIds(): PersistentList = + pages().map { it.id }.toPersistentList() } diff --git a/app/src/test/java/org/fairscan/app/domain/RotationTest.kt b/app/src/test/java/org/fairscan/app/domain/RotationTest.kt new file mode 100644 index 0000000..5af4b6c --- /dev/null +++ b/app/src/test/java/org/fairscan/app/domain/RotationTest.kt @@ -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 . + */ +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) + } +}