Revamp ImageRepository
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 pages(): List<ScanPage> =
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -28,27 +28,28 @@ 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 ->
|
||||
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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
47
app/src/test/java/org/fairscan/app/domain/RotationTest.kt
Normal file
47
app/src/test/java/org/fairscan/app/domain/RotationTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user