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 {