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.recentDocumentsDataStore
import org.fairscan.app.domain.ImageSegmentationService
import org.fairscan.app.platform.AndroidImageLoader
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.home.HomeViewModel
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 logger = FileLogger(logRepository)
val imageSegmentationService = ImageSegmentationService(context, logger)
val imageLoader = AndroidImageLoader(context.contentResolver)
val recentDocumentsDataStore = context.recentDocumentsDataStore
val settingsRepository = SettingsRepository(context)

View File

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

View File

@@ -16,6 +16,7 @@ package org.fairscan.app.domain
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import org.fairscan.imageprocessing.decodeJpeg
import org.fairscan.imageprocessing.encodeJpeg
import org.opencv.core.Mat
@@ -27,3 +28,7 @@ class Jpeg(val bytes: ByteArray) {
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
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.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
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.RoundedCornerShape
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.Highlight
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -123,6 +127,7 @@ fun CameraScreen(
onImageAnalyzed: (ImageProxy) -> Unit,
onFinalizePressed: () -> Unit,
cameraPermission: CameraPermissionState,
onImportClicked: () -> Unit,
) {
var previewView by remember { mutableStateOf<PreviewView?>(null) }
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
@@ -227,8 +232,9 @@ fun CameraScreen(
thumbnailCoords = thumbnailCoords,
navigation = navigation,
captureController,
cameraPermission.isGranted,
{ cameraPermission.request() },
isCameraPermissionGranted = cameraPermission.isGranted,
onRequestCameraPermission = { cameraPermission.request() },
onImportClicked = onImportClicked,
)
}
@@ -246,6 +252,7 @@ private fun CameraScreenScaffold(
captureController: CameraCaptureController,
isCameraPermissionGranted: Boolean,
onRequestCameraPermission: () -> Unit,
onImportClicked: () -> Unit,
) {
var focusPoint by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(focusPoint) {
@@ -277,7 +284,7 @@ private fun CameraScreenScaffold(
navigation = navigation,
pageListState = pageListState,
onBack = navigation.back,
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed) }
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed, onImportClicked) }
) { modifier ->
if (!isCameraPermissionGranted) {
CameraPermissionRationale(onRequestCameraPermission, modifier)
@@ -518,12 +525,26 @@ fun MessageBox(inferenceTime: Long) {
private fun Bar(
pageCount: Int,
onFinalizePressed: () -> Unit,
onImportClicked: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
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(
onClick = onFinalizePressed,
enabled = pageCount > 0,
@@ -637,7 +658,8 @@ private fun ScreenPreview(
navigation = dummyNavigation(),
captureController = CameraCaptureController(),
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.Matrix
import android.net.Uri
import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -42,6 +43,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val imageSegmentationService = appContainer.imageSegmentationService
private val settingsRepository = appContainer.settingsRepository
private val imageLoader = appContainer.imageLoader
private val logger = appContainer.logger
private val _events = MutableSharedFlow<CameraEvent>()
@@ -184,6 +186,18 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
fun setTorchEnabled(enabled: Boolean) {
_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 {