Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/main/java/org/fairscan/app/FairScanApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/org/fairscan/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -122,6 +123,7 @@ class MainActivity : ComponentActivity() {
val context = LocalContext.current
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -166,14 +168,24 @@ class MainActivity : ComponentActivity() {
)
}
is Screen.Main.Camera -> {
val pickMultiple = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(10)) {
uris -> cameraViewModel.importPhotos(uris)
}
CameraScreen(
viewModel,
cameraViewModel,
navigation,
liveAnalysisState,
importState,
onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
onFinalizePressed = onExportClick,
cameraPermission = cameraPermission
cameraPermission = cameraPermission,
onImportClicked = {
cameraViewModel.onImportClicked()
pickMultiple.launch(PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly))
}
)
}
is Screen.Main.Document -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
92 changes: 92 additions & 0 deletions app/src/main/java/org/fairscan/app/platform/AndroidImageLoader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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
import java.io.IOException

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 options = BitmapFactory.Options()
val inputStream = contentResolver.openInputStream(uri)
?: throw IOException("Cannot open input stream for uri: $uri")
return inputStream.use {
BitmapFactory.decodeStream(it, null, options)
?: throw IOException("Failed to decode bitmap for uri: $uri")
}
}

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
}
}
Loading
Loading