diff --git a/app/src/main/java/org/fairscan/app/FairScanApp.kt b/app/src/main/java/org/fairscan/app/FairScanApp.kt
index 25cd870..2db3aa1 100644
--- a/app/src/main/java/org/fairscan/app/FairScanApp.kt
+++ b/app/src/main/java/org/fairscan/app/FairScanApp.kt
@@ -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)
diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt
index 4c86cfa..551f30a 100644
--- a/app/src/main/java/org/fairscan/app/MainActivity.kt
+++ b/app/src/main/java/org/fairscan/app/MainActivity.kt
@@ -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 -> {
diff --git a/app/src/main/java/org/fairscan/app/domain/Jpeg.kt b/app/src/main/java/org/fairscan/app/domain/Image.kt
similarity index 93%
rename from app/src/main/java/org/fairscan/app/domain/Jpeg.kt
rename to app/src/main/java/org/fairscan/app/domain/Image.kt
index edb388e..f3897d2 100644
--- a/app/src/main/java/org/fairscan/app/domain/Jpeg.kt
+++ b/app/src/main/java/org/fairscan/app/domain/Image.kt
@@ -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
+}
diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidImageLoader.kt b/app/src/main/java/org/fairscan/app/platform/AndroidImageLoader.kt
new file mode 100644
index 0000000..3a15e64
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/platform/AndroidImageLoader.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt
index 88a522d..062575a 100644
--- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt
+++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt
@@ -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(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(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 = {},
)
}
}
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt
index fa548a2..0180d4c 100644
--- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt
+++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt
@@ -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()
@@ -184,6 +186,18 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
fun setTorchEnabled(enabled: Boolean) {
_isTorchEnabled.value = enabled
}
+
+ fun importPhotos(uris: List) {
+ 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 {