Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion app/src/main/java/org/mydomain/myscan/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.mydomain.myscan.ui.theme.MyScanTheme
import org.mydomain.myscan.view.CameraScreen
import org.mydomain.myscan.view.CaptureValidationScreen
import org.mydomain.myscan.view.DocumentScreen
import org.opencv.android.OpenCVLoader
import java.io.File
Expand All @@ -56,17 +57,34 @@ class MainActivity : ComponentActivity() {
MyScanTheme {

Column {
when (currentScreen) {
when (val screen = currentScreen) {
is Screen.Camera -> {
Scaffold { innerPadding->
CameraScreen(
viewModel, liveAnalysisState,
onImageAnalyzed = { image -> viewModel.segment(image) },
onFinalizePressed = { viewModel.navigateTo(Screen.FinalizeDocument) },
onCapture = { imageProxy -> viewModel.navigateTo(Screen.CaptureValidation(imageProxy)) },
modifier = Modifier.padding(innerPadding)
)
}
}
is Screen.CaptureValidation -> {
CaptureValidationScreen(
imageProxy = screen.imageProxy,
viewModel = viewModel,
onConfirm = { image ->
viewModel.addPage(image)
viewModel.navigateTo(Screen.Camera)
},
onReject = {
viewModel.navigateTo(Screen.Camera)
},
onError = {
viewModel.navigateTo(Screen.Camera)
}
)
}
is Screen.FinalizeDocument -> {
DocumentScreen (
pageIds,
Expand Down
18 changes: 7 additions & 11 deletions app/src/main/java/org/mydomain/myscan/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package org.mydomain.myscan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
Expand Down Expand Up @@ -56,11 +57,6 @@ class MainViewModel(
private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds())
val pageIds: StateFlow<List<String>> = _pageIds

private var _pageToValidate = MutableStateFlow<Bitmap?>(null)
val pageToValidate: StateFlow<Bitmap?> = _pageToValidate.asStateFlow()

var liveAnalysisEnabled = true

init {
viewModelScope.launch {
imageSegmentationService.initialize()
Expand All @@ -81,10 +77,7 @@ class MainViewModel(
}

fun segment(imageProxy: ImageProxy) {
if (!liveAnalysisEnabled) {
imageProxy.close()
return
}
Log.i("MyScan", "live analysis triggered: start")

viewModelScope.launch {
imageSegmentationService.runSegmentationAndEmit(
Expand All @@ -93,17 +86,20 @@ class MainViewModel(
)
imageProxy.close()
}
Log.i("MyScan", "live analysis triggered: end")
}

fun navigateTo(screen: Screen) {
_currentScreen.value = screen
}

fun processCapturedImageThen(imageProxy: ImageProxy, onResult: (Bitmap?) -> Unit) {
Log.i("MyScan", "processCapturedImageThen")
viewModelScope.launch {
_pageToValidate.value = processCapturedImage(imageProxy)
val processed = processCapturedImage(imageProxy)
imageProxy.close()
onResult(_pageToValidate.value)
onResult(processed)
Log.i("MyScan", "End of processing for captured image")
}
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/org/mydomain/myscan/Navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
*/
package org.mydomain.myscan

import androidx.camera.core.ImageProxy

sealed class Screen {
object Camera : Screen()
object FinalizeDocument : Screen()
data class CaptureValidation(val imageProxy: ImageProxy) : Screen()
}
40 changes: 4 additions & 36 deletions app/src/main/java/org/mydomain/myscan/view/Camera.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
Expand All @@ -75,7 +74,6 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.scale
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.common.util.concurrent.ListenableFuture
import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.MainViewModel
Expand All @@ -93,12 +91,9 @@ fun CameraScreen(
liveAnalysisState: LiveAnalysisState,
onImageAnalyzed: (ImageProxy) -> Unit,
onFinalizePressed: () -> Unit,
onCapture: (ImageProxy) -> Unit,
modifier: Modifier,
) {
val showPageDialog = rememberSaveable { mutableStateOf(false) }
val isProcessing = rememberSaveable { mutableStateOf(false) }
val pageToValidate by viewModel.pageToValidate.collectAsStateWithLifecycle()

val context = LocalContext.current
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
Expand Down Expand Up @@ -128,47 +123,20 @@ fun CameraScreen(
captureController = captureController
) },
pageCount = viewModel.pageCount(),
liveAnalysisState = if (showPageDialog.value) LiveAnalysisState() else liveAnalysisState,
liveAnalysisState = liveAnalysisState,
onCapture = {
Log.i("MyScan", "Pressed <Capture>")
viewModel.liveAnalysisEnabled = false
showPageDialog.value = true
isProcessing.value = true
captureController.takePicture(
onImageCaptured = { imageProxy ->
if (imageProxy != null) {
viewModel.processCapturedImageThen(imageProxy) {
isProcessing.value = false
viewModel.liveAnalysisEnabled = true
Log.i("MyScan", "Capture process finished")
}
onCapture(imageProxy)
} else {
Log.e("MyScan", "Error during image capture")
isProcessing.value = false
viewModel.liveAnalysisEnabled = true
}
}
)
},
)},
onFinalizePressed = onFinalizePressed
)

if (showPageDialog.value) {
PageValidationDialog(
isProcessing = isProcessing.value,
pageBitmap = pageToValidate,
onConfirm = {
pageToValidate?.let { viewModel.addPage(it) }
showPageDialog.value = false
},
onReject = {
showPageDialog.value = false
},
onDismiss = {
showPageDialog.value = false
}
)
}
}

@Composable
Expand Down
164 changes: 164 additions & 0 deletions app/src/main/java/org/mydomain/myscan/view/CaptureValidation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* 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.mydomain.myscan.view

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.camera.core.ImageProxy
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.mydomain.myscan.MainViewModel
import org.mydomain.myscan.ui.theme.MyScanTheme

@Composable
fun CaptureValidationScreen(
imageProxy: ImageProxy,
viewModel: MainViewModel,
onConfirm: (Bitmap) -> Unit,
onReject: () -> Unit,
onError: () -> Unit
) {
var isLoading by remember { mutableStateOf(true) }
var resultBitmap by remember { mutableStateOf<Bitmap?>(null) }
var hasBeenLaunched by remember { mutableStateOf(false) }

// Start processing only once
if (!hasBeenLaunched) {
hasBeenLaunched = true
viewModel.processCapturedImageThen(imageProxy) { result ->
resultBitmap = result
isLoading = false
}
}

CaptureValidationContent(isLoading, resultBitmap, onError, onReject, onConfirm)
}

@Composable
private fun CaptureValidationContent(
isLoading: Boolean,
resultBitmap: Bitmap?,
onError: () -> Unit,
onReject: () -> Unit,
onConfirm: (Bitmap) -> Unit
) {
Scaffold { innerPadding ->
Box(modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(innerPadding)) {
when {
isLoading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}

resultBitmap == null -> {
Text(
"Document not detected",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.Center)
)
Button(
onClick = onError,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Text("OK")
}
}

else -> {
Image(
bitmap = resultBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(16.dp),
contentScale = ContentScale.Fit
)
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(onClick = onReject, modifier = Modifier.weight(1f)) {
Text("Reject")
}
Button(
onClick = {onConfirm(resultBitmap)},
modifier = Modifier.weight(1f)
) {
Text("OK")
}
}
}
}
}
}
}

@Preview(showBackground = true)
@Composable
fun CaptureValidationPreview() {
val context = LocalContext.current
val bitmap = context.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg")
.use { input -> BitmapFactory.decodeStream(input) }
MyScanTheme {
CaptureValidationContent(
isLoading = false,
resultBitmap = bitmap,
onError = {},
onConfirm = {},
onReject = {}
)
}
}

@Preview(showBackground = true)
@Composable
fun CaptureValidationWithoutImagePreview() {
MyScanTheme {
CaptureValidationContent(
isLoading = false,
resultBitmap = null,
onError = {},
onConfirm = {},
onReject = {}
)
}
}
Loading
Loading