New feature: import photos
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user