Skip to content
Merged
14 changes: 13 additions & 1 deletion app/src/main/java/org/fairscan/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.crop.CropScreen
import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.about.AboutEvent
import org.fairscan.app.ui.screens.about.AboutScreen
Expand All @@ -62,7 +64,6 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.export.ExportActions
import org.fairscan.app.ui.screens.export.ExportEvent
import org.fairscan.app.ui.screens.export.ExportResult
Expand Down Expand Up @@ -123,6 +124,7 @@ class MainActivity : ComponentActivity() {
val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState()
CollectCameraEvents(cameraViewModel, viewModel)
Expand Down Expand Up @@ -177,6 +179,14 @@ class MainActivity : ComponentActivity() {
}
)
}
is Screen.Main.EditImage -> {
CropScreen(
pageId = documentUiState.currentPage?.key?.pageId ?: "",
initState = cropInitialState,
navigation = navigation,
onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) },
)
}
is Screen.Main.Document -> {
DocumentScreen (
uiState = documentUiState,
Expand All @@ -185,6 +195,7 @@ class MainActivity : ComponentActivity() {
onDeleteImage = { viewModel.deleteCurrentPage() },
onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) },
onToggleColorMode = { viewModel.toggleCurrentPageColorMode() },
onCropClick = { viewModel.onClickOnCropButton() },
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
onPageSelected = viewModel::onPageSelected
)
Expand Down Expand Up @@ -457,6 +468,7 @@ class MainActivity : ComponentActivity() {

private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) },
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
Expand Down
70 changes: 69 additions & 1 deletion app/src/main/java/org/fairscan/app/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@
*/
package org.fairscan.app

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
Expand All @@ -34,14 +38,18 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.Rotation
import org.fairscan.app.domain.ScanPage
import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.screens.document.CurrentPageUiState
import org.fairscan.app.ui.screens.document.DocumentUiState
import org.fairscan.app.ui.screens.crop.CropInitState
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Quad
import kotlin.math.min

@OptIn(ExperimentalCoroutinesApi::class)
Expand Down Expand Up @@ -95,7 +103,8 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
page?.let {
val isLoading = (it.id == loadingId)
val bitmap = imageRepository.jpegBytes(it.key())?.toBitmap()
CurrentPageUiState(it.key(), bitmap, it.colorMode, isLoading)
val canBeCropped = page.metadata != null
CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading)
}
}
.flowOn(Dispatchers.IO)
Expand Down Expand Up @@ -181,6 +190,22 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
}
}

fun setCurrentPageUserQuad(userQuad: Quad) {
viewModelScope.launch {
val currentPage = currentPage()
val totalRotation = currentPage.totalRotation()
val rotateIterations = (4 - totalRotation.degrees / 90) % 4
val newQuad = userQuad.rotate90(rotateIterations, ImageSize(1, 1))
_loadingPageId.value = currentPage.id
val pages = withContext(Dispatchers.IO) {
imageRepository.setUserQuad(currentPage.id, newQuad)
imageRepository.pages()
}
_pages.value = pages
_loadingPageId.value = null
}
}

private fun currentPage(): ScanPage {
val index = _currentPageIndex.value
val pages = _pages.value
Expand Down Expand Up @@ -212,4 +237,47 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
_pages.value = pages
}
}

private val _cropInitState = MutableStateFlow<CropInitState>(CropInitState.Loading)
val cropInitState: StateFlow<CropInitState> = _cropInitState

private var cropInitialStateJob: Job? = null
fun onClickOnCropButton() {
cropInitialStateJob?.cancel()
cropInitialStateJob = viewModelScope.launch {
_cropInitState.value = CropInitState.Loading

val page = currentPage()

val metadata = page.metadata
val rotation = page.totalRotation()

val bitmap = withContext(Dispatchers.IO) {
val source = imageRepository.source(page.id)
val bytes = source?.bytes ?: return@withContext null

val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (original != null && rotation != Rotation.R0) {
val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) }
Bitmap.createBitmap(
original, 0, 0, original.width, original.height, matrix, true
)
} else {
original
}
}

val quad = metadata?.normalizedQuad?.rotate90(
rotation.degrees / 90,
ImageSize(1, 1)
)

_cropInitState.value = if (bitmap == null || quad == null)
CropInitState.Error
else
CropInitState.Ready(page.id, bitmap, quad)
navigateTo(Screen.Main.EditImage)
}

}
}
2 changes: 2 additions & 0 deletions app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ data class PageV2(
val baseRotationDegrees: Int = 0,
val manualRotationDegrees: Int = 0,
val quad: NormalizedQuad? = null,
val quadVersion: Int = 0,
val userQuad: NormalizedQuad? = null,
val isColored: Boolean? = null,
val colorMode: ColorMode? = null,
)
Expand Down
83 changes: 66 additions & 17 deletions app/src/main/java/org/fairscan/app/data/ImageRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ 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.ExportQuality
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
Expand Down Expand Up @@ -91,7 +90,7 @@ class ImageRepository(
return when {
metadataPages != null ->
metadataPages
.filter { processedImageFileName(it.id, it.colorMode) in filesOnDisk }
.filter { processedImageFileName(it.id, it.colorMode, it.quadVersion) in filesOnDisk }
.toMutableList()
else ->
filesOnDisk
Expand Down Expand Up @@ -135,15 +134,15 @@ class ImageRepository(
pages.pages().mapNotNull {
runCatching {
val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees)
ScanPage(it.id, manualRotation, it.colorMode, it.toMetadata())
ScanPage(it.id, manualRotation, it.colorMode, it.quadVersion, it.toMetadata())
}.getOrNull()
}
}

suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) =
mutex.withLock {
val id = "${System.currentTimeMillis()}"
val key = PageViewKey(id, Rotation.R0, colorMode)
val key = PageViewKey(id, Rotation.R0, colorMode, 0)
processedImageFile(key).writeBytes(processed.bytes)
sourceFile(id).writeBytes(source.bytes)
pages.addOrReplace(
Expand All @@ -162,18 +161,64 @@ class ImageRepository(
}

suspend fun setColorMode(id: String, colorMode: ColorMode) {
val key = PageViewKey(id, Rotation.R0, colorMode)
val processedFile = processedImageFile(key)
val metadata = mutex.withLock { pages.get(id)?.toMetadata() }
updatePage(id) { page, metadata ->
PageUpdate(
updatedPage = page.copy(colorMode = colorMode),
normalizedQuad = metadata.normalizedQuad,
colorMode = colorMode,
)
}
}

suspend fun setUserQuad(id: String, newQuad: Quad) {
updatePage(id) { page, metadata ->
PageUpdate(
updatedPage = page.copy(
quadVersion = page.quadVersion + 1,
userQuad = newQuad.toSerializable(),
),
normalizedQuad = newQuad,
colorMode = page.colorMode ?: metadata.autoColorMode,
)
}
}

private data class PageUpdate(
val updatedPage: PageV2,
val normalizedQuad: Quad,
val colorMode: ColorMode,
)

private suspend fun updatePage(
id: String,
buildUpdate: (PageV2, PageMetadata) -> PageUpdate
) {
val page = mutex.withLock { pages.get(id) }
val metadata = page?.toMetadata() ?: return
val sourceFile = sourceFile(id)
if (metadata == null || !sourceFile.exists())
if (!sourceFile.exists())
return

val update = buildUpdate(page, metadata)
val key = PageViewKey(
pageId = id,
rotation = Rotation.R0,
colorMode = update.colorMode,
quadVersion = update.updatedPage.quadVersion
)

val processedFile = processedImageFile(key)
val job = processingJobs.computeIfAbsent(key) {
scope.async(Dispatchers.IO) {
if (!processedFile.exists()) {
val sourceJpeg = Jpeg(sourceFile.readBytes())
val processedJpeg = transformations.process(sourceJpeg, metadata, colorMode)
val processedJpeg =
transformations.process(
sourceJpeg,
normalizedQuad = update.normalizedQuad,
baseRotation = metadata.baseRotation,
colorMode = update.colorMode
)
processedFile.writeBytes(processedJpeg.bytes)
}
}
Expand All @@ -185,7 +230,7 @@ class ImageRepository(
}

mutex.withLock {
pages.update(id) { it.copy(colorMode = colorMode) }
pages.update(id) { update.updatedPage }
saveMetadata()
}
}
Expand Down Expand Up @@ -248,14 +293,18 @@ class ImageRepository(

// --- Other operations ---

private fun processedImageFileName(id: String, colorMode: ColorMode?) : String =
if (colorMode == null)
"${id}.jpg"
else
"${id}.${colorMode.name.lowercase()}.jpg"
private fun processedImageFileName(id: String, colorMode: ColorMode?, quadVersion: Int) : String {
val sb = StringBuilder(id)
if (colorMode != null)
sb.append(".").append(colorMode.name.lowercase())
if (quadVersion > 0)
sb.append(".q").append(quadVersion)
sb.append(".jpg")
return sb.toString()
}

private fun processedImageFile(key: PageViewKey) : File =
File(processedDir, processedImageFileName(key.pageId, key.colorMode))
File(processedDir, processedImageFileName(key.pageId, key.colorMode, key.quadVersion))

private fun sourceFile(id: String): File =
File(sourceDir, "$id.jpg")
Expand Down Expand Up @@ -352,7 +401,7 @@ fun NormalizedQuad.toQuad(): Quad =
fun PageV2.toMetadata(): PageMetadata? {
if (quad == null || isColored == null) return null
return PageMetadata(
quad.toQuad(),
(userQuad ?: quad).toQuad(),
Rotation.fromDegrees(baseRotationDegrees),
if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE
)
Expand Down
10 changes: 8 additions & 2 deletions app/src/main/java/org/fairscan/app/data/ImageTransformations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,21 @@
package org.fairscan.app.data

import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.Quad

interface ImageTransformations {

fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg

fun resizeToThumbnail(input: Jpeg): Jpeg

fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg
fun process(
source: Jpeg,
normalizedQuad: Quad,
baseRotation: Rotation,
colorMode: ColorMode
): Jpeg

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ suspend fun jpegsForExport(
JpegProvider {
val source = imageRepository.source(page.id)
val metadata = page.metadata
val manualRotation = page.manualRotation
val colorMode = page.colorMode
if (source != null && metadata != null && colorMode != null) {
val rotation = metadata.baseRotation.add(manualRotation)
processedImage(source, metadata, rotation, colorMode, exportQuality)
val rotation = page.totalRotation()
val normalizedQuad = metadata.normalizedQuad
processedImage(source, normalizedQuad, rotation, colorMode, exportQuality)
}
else
jpeg(page, imageRepository)
Expand Down
10 changes: 5 additions & 5 deletions app/src/main/java/org/fairscan/app/domain/Page.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ data class ScanPage(
val id: String,
val manualRotation: Rotation,
val colorMode: ColorMode?,
val quadVersion: Int,
val metadata: PageMetadata?,
) {
fun key(): PageViewKey = PageViewKey(id, manualRotation, colorMode)
fun key() = PageViewKey(id, manualRotation, colorMode, quadVersion)
fun totalRotation() = manualRotation.add(metadata?.baseRotation ?: Rotation.R0)
}

data class PageViewKey(
val pageId: String,
val rotation: Rotation,
val colorMode: ColorMode?,
) {
val saveKey: String get() = "$pageId-${rotation.degrees}-$colorMode"
}

val quadVersion: Int,
)
enum class Rotation(val degrees: Int) {
R0(0),
R90(90),
Expand Down
Loading
Loading