New feature: import photos

This commit is contained in:
Pierre-Yves Nicolas
2026-04-07 21:07:00 +02:00
parent 2f4b330b8d
commit b946913594
6 changed files with 146 additions and 7 deletions

View File

@@ -25,8 +25,8 @@ import org.fairscan.app.data.FileManager
import org.fairscan.app.data.LogRepository import org.fairscan.app.data.LogRepository
import org.fairscan.app.data.recentDocumentsDataStore import org.fairscan.app.data.recentDocumentsDataStore
import org.fairscan.app.domain.ImageSegmentationService import org.fairscan.app.domain.ImageSegmentationService
import org.fairscan.app.platform.AndroidImageLoader
import org.fairscan.app.platform.AndroidPdfWriter import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.ui.screens.about.AboutViewModel
import org.fairscan.app.ui.screens.camera.CameraViewModel import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.home.HomeViewModel import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.screens.settings.SettingsRepository import org.fairscan.app.ui.screens.settings.SettingsRepository
@@ -56,6 +56,7 @@ class AppContainer(context: Context) {
val logRepository = LogRepository(File(context.filesDir, "logs.txt")) val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
val logger = FileLogger(logRepository) val logger = FileLogger(logRepository)
val imageSegmentationService = ImageSegmentationService(context, logger) val imageSegmentationService = ImageSegmentationService(context, logger)
val imageLoader = AndroidImageLoader(context.contentResolver)
val recentDocumentsDataStore = context.recentDocumentsDataStore val recentDocumentsDataStore = context.recentDocumentsDataStore
val settingsRepository = SettingsRepository(context) val settingsRepository = SettingsRepository(context)

View File

@@ -33,6 +33,7 @@ import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -166,6 +167,10 @@ class MainActivity : ComponentActivity() {
) )
} }
is Screen.Main.Camera -> { is Screen.Main.Camera -> {
val pickMultiple = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(10)) { uris ->
if (uris.isNotEmpty()) cameraViewModel.importPhotos(uris)
}
CameraScreen( CameraScreen(
viewModel, viewModel,
cameraViewModel, cameraViewModel,
@@ -173,7 +178,11 @@ class MainActivity : ComponentActivity() {
liveAnalysisState, liveAnalysisState,
onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) }, onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
onFinalizePressed = onExportClick, onFinalizePressed = onExportClick,
cameraPermission = cameraPermission cameraPermission = cameraPermission,
onImportClicked = {
pickMultiple.launch(PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly))
}
) )
} }
is Screen.Main.Document -> { is Screen.Main.Document -> {

View File

@@ -16,6 +16,7 @@ package org.fairscan.app.domain
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import org.fairscan.imageprocessing.decodeJpeg import org.fairscan.imageprocessing.decodeJpeg
import org.fairscan.imageprocessing.encodeJpeg import org.fairscan.imageprocessing.encodeJpeg
import org.opencv.core.Mat import org.opencv.core.Mat
@@ -27,3 +28,7 @@ class Jpeg(val bytes: ByteArray) {
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
fun toMat() : Mat = decodeJpeg(bytes) fun toMat() : Mat = decodeJpeg(bytes)
} }
interface ImageLoader {
suspend fun load(uri: Uri): Bitmap
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2025-2026 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.platform
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.util.component1
import androidx.core.util.component2
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.fairscan.app.domain.ImageLoader
class AndroidImageLoader(
private val contentResolver: ContentResolver
) : ImageLoader {
override suspend fun load(uri: Uri): Bitmap {
val bitmap = loadBitmapFromUri(contentResolver, uri)
return ensureArgb8888(bitmap)
}
}
suspend fun loadBitmapFromUri(
contentResolver: ContentResolver,
uri: Uri,
maxPixels: Int = 12_000_000,
): Bitmap = withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= 28) {
decodeWithImageDecoder(contentResolver, uri, maxPixels)
} else {
decodeWithBitmapFactory(contentResolver, uri)
}
}
@RequiresApi(28)
private fun decodeWithImageDecoder(
contentResolver: ContentResolver,
uri: Uri,
maxPixels: Int
): Bitmap {
val source = ImageDecoder.createSource(contentResolver, uri)
return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
val (width, height) = info.size
val scale = computeScale(width, height, maxPixels)
decoder.setTargetSize((width * scale).toInt(), (height * scale).toInt())
}
}
private fun decodeWithBitmapFactory(contentResolver: ContentResolver, uri: Uri, ): Bitmap {
val decodeOptions = BitmapFactory.Options()
return contentResolver.openInputStream(uri).use {
BitmapFactory.decodeStream(it, null, decodeOptions)
}!!
}
private fun computeScale(width: Int, height: Int, maxPixels: Int): Float {
val pixels = width * height
return if (pixels > maxPixels) {
maxPixels.toFloat() / pixels
} else {
1f
}
}
private fun ensureArgb8888(bitmap: Bitmap): Bitmap {
return if (bitmap.config != Bitmap.Config.ARGB_8888) {
bitmap.copy(Bitmap.Config.ARGB_8888, true)
} else {
bitmap
}
}

View File

@@ -22,6 +22,7 @@ import androidx.camera.view.PreviewView
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
@@ -47,13 +48,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Highlight import androidx.compose.material.icons.filled.Highlight
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -123,6 +127,7 @@ fun CameraScreen(
onImageAnalyzed: (ImageProxy) -> Unit, onImageAnalyzed: (ImageProxy) -> Unit,
onFinalizePressed: () -> Unit, onFinalizePressed: () -> Unit,
cameraPermission: CameraPermissionState, cameraPermission: CameraPermissionState,
onImportClicked: () -> Unit,
) { ) {
var previewView by remember { mutableStateOf<PreviewView?>(null) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
@@ -227,8 +232,9 @@ fun CameraScreen(
thumbnailCoords = thumbnailCoords, thumbnailCoords = thumbnailCoords,
navigation = navigation, navigation = navigation,
captureController, captureController,
cameraPermission.isGranted, isCameraPermissionGranted = cameraPermission.isGranted,
{ cameraPermission.request() }, onRequestCameraPermission = { cameraPermission.request() },
onImportClicked = onImportClicked,
) )
} }
@@ -246,6 +252,7 @@ private fun CameraScreenScaffold(
captureController: CameraCaptureController, captureController: CameraCaptureController,
isCameraPermissionGranted: Boolean, isCameraPermissionGranted: Boolean,
onRequestCameraPermission: () -> Unit, onRequestCameraPermission: () -> Unit,
onImportClicked: () -> Unit,
) { ) {
var focusPoint by remember { mutableStateOf<Offset?>(null) } var focusPoint by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(focusPoint) { LaunchedEffect(focusPoint) {
@@ -277,7 +284,7 @@ private fun CameraScreenScaffold(
navigation = navigation, navigation = navigation,
pageListState = pageListState, pageListState = pageListState,
onBack = navigation.back, onBack = navigation.back,
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed) } bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed, onImportClicked) }
) { modifier -> ) { modifier ->
if (!isCameraPermissionGranted) { if (!isCameraPermissionGranted) {
CameraPermissionRationale(onRequestCameraPermission, modifier) CameraPermissionRationale(onRequestCameraPermission, modifier)
@@ -518,12 +525,26 @@ fun MessageBox(inferenceTime: Long) {
private fun Bar( private fun Bar(
pageCount: Int, pageCount: Int,
onFinalizePressed: () -> Unit, onFinalizePressed: () -> Unit,
onImportClicked: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
OutlinedButton(
onClick = onImportClicked,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
) {
Icon(
Icons.Default.AddPhotoAlternate,
// TODO Externalize string
contentDescription = "Import photos",)
}
MainActionButton( MainActionButton(
onClick = onFinalizePressed, onClick = onFinalizePressed,
enabled = pageCount > 0, enabled = pageCount > 0,
@@ -637,7 +658,8 @@ private fun ScreenPreview(
navigation = dummyNavigation(), navigation = dummyNavigation(),
captureController = CameraCaptureController(), captureController = CameraCaptureController(),
isCameraPermissionGranted = isCameraPermissionGranted, isCameraPermissionGranted = isCameraPermissionGranted,
onRequestCameraPermission = {} onRequestCameraPermission = {},
onImportClicked = {},
) )
} }
} }

View File

@@ -16,6 +16,7 @@ package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix import android.graphics.Matrix
import android.net.Uri
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -42,6 +43,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val imageSegmentationService = appContainer.imageSegmentationService private val imageSegmentationService = appContainer.imageSegmentationService
private val settingsRepository = appContainer.settingsRepository private val settingsRepository = appContainer.settingsRepository
private val imageLoader = appContainer.imageLoader
private val logger = appContainer.logger private val logger = appContainer.logger
private val _events = MutableSharedFlow<CameraEvent>() private val _events = MutableSharedFlow<CameraEvent>()
@@ -184,6 +186,18 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
fun setTorchEnabled(enabled: Boolean) { fun setTorchEnabled(enabled: Boolean) {
_isTorchEnabled.value = enabled _isTorchEnabled.value = enabled
} }
fun importPhotos(uris: List<Uri>) {
viewModelScope.launch {
for (uri in uris) {
val photoToImport = imageLoader.load(uri)
val page = processCapturedImage(photoToImport, 0)
page?.let {
_events.emit(CameraEvent.ImageCaptured(it))
}
}
}
}
} }
sealed class CaptureState { sealed class CaptureState {