From 843deaaafac125f695477a71c8a301abd6e86c75 Mon Sep 17 00:00:00 2001 From: JFly02 Date: Fri, 10 Apr 2026 21:43:04 +0200 Subject: [PATCH] Add OCR PDF export and DPI presets --- app/build.gradle.kts | 1 + .../chrisimx/scanbridge/AppSettingsScreen.kt | 86 ++++++++ .../chrisimx/scanbridge/ScanningScreen.kt | 33 ++- .../ui/ScanSettingsComposableStateHolder.kt | 7 +- .../data/ui/ScanningScreenViewModel.kt | 197 +++++++++++++----- .../scanbridge/datastore/DataStores.kt | 1 + .../datastore/ScanBridgeSettingsSerializer.kt | 1 + .../datastore/ScanSettingsDataStoreHelpers.kt | 6 + .../uicomponents/ExportSettingsPopup.kt | 62 ++++-- .../util/DefaultScanDpiPreferenceUtil.kt | 25 +++ .../scanbridge/util/DocumentOcrUtil.kt | 104 +++++++++ .../scanbridge/util/ESCLKtExtensions.kt | 37 +++- app/src/main/proto/app_settings.proto | 3 +- .../res/drawable/baseline_description_24.xml | 9 + app/src/main/res/values-de/strings.xml | 12 +- app/src/main/res/values-it/strings.xml | 7 +- app/src/main/res/values/strings.xml | 12 +- .../DefaultScanDpiPreferenceUtilTest.kt | 23 ++ .../ScanResolutionPreferenceUtilTest.kt | 36 ++++ gradle/libs.versions.toml | 4 +- 20 files changed, 584 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/util/DefaultScanDpiPreferenceUtil.kt create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/util/DocumentOcrUtil.kt create mode 100644 app/src/main/res/drawable/baseline_description_24.xml create mode 100644 app/src/test/java/org/github/chrisimx/scanbridge/DefaultScanDpiPreferenceUtilTest.kt create mode 100644 app/src/test/java/org/github/chrisimx/scanbridge/ScanResolutionPreferenceUtilTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 01f50bcc..07624ae8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { implementation(libs.androidx.material.icons.core) implementation(libs.androidx.constraintlayout.compose) implementation(libs.itext7.core) + implementation(libs.mlkit.text.recognition) implementation(libs.ktor.okhttp) implementation(libs.ktor.logging) "playImplementation"(project(":lvl_library")) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt index 7ae82423..f56e7d12 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedIconButton @@ -60,12 +61,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import androidx.core.net.toUri import io.github.chrisimx.scanbridge.datastore.* import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings +import io.github.chrisimx.scanbridge.proto.defaultScanDpiOrNull import io.github.chrisimx.scanbridge.proto.rememberScanSettingsOrNull import io.github.chrisimx.scanbridge.services.DebugLogService import io.github.chrisimx.scanbridge.uicomponents.TitledCard @@ -74,6 +77,9 @@ import io.github.chrisimx.scanbridge.uicomponents.settings.CheckboxSetting import io.github.chrisimx.scanbridge.uicomponents.settings.MoreInformationButton import io.github.chrisimx.scanbridge.uicomponents.settings.UIntSetting import io.github.chrisimx.scanbridge.uicomponents.settings.VersionComposable +import io.github.chrisimx.scanbridge.util.DEFAULT_SCAN_DPI_MAX +import io.github.chrisimx.scanbridge.util.defaultScanDpiPresets +import io.github.chrisimx.scanbridge.util.normalizeDefaultScanDpiPreference import java.io.File import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -91,6 +97,76 @@ fun DisableCertChecksSetting(onInformationRequested: (Int) -> Unit, checked: Boo } } +@Composable +fun DefaultScanDpiSetting( + selectedValue: UInt, + onInformationRequested: (Int) -> Unit, + setValue: (UInt) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + ) { + ConstraintLayout( + Modifier.fillMaxWidth() + ) { + val (title, informationButton) = createRefs() + + Text( + text = stringResource(R.string.default_scan_dpi), + modifier = Modifier.constrainAs(title) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(informationButton.start, 12.dp) + width = Dimension.fillToConstraints + }, + style = MaterialTheme.typography.bodyMedium + ) + + Box( + modifier = Modifier.constrainAs(informationButton) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + ) { + MoreInformationButton { + onInformationRequested(R.string.default_scan_dpi_info) + } + } + } + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + defaultScanDpiPresets.forEach { preset -> + val label = if (preset == DEFAULT_SCAN_DPI_MAX) { + stringResource(R.string.max_short) + } else { + preset.toString() + } + + InputChip( + selected = selectedValue == preset, + onClick = { setValue(preset) }, + label = { + Text( + label, + textAlign = TextAlign.Center + ) + } + ) + } + } + } +} + fun exportDebugLog(context: Context, debugLogService: DebugLogService, saveDebugLogLauncher: ActivityResultLauncher) { debugLogService.flush() @@ -267,6 +343,16 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { information = it } + DefaultScanDpiSetting( + normalizeDefaultScanDpiPreference(appSettings.defaultScanDpiOrNull?.value?.toUInt()), + setInformationRequested, + { selectedValue -> + coroutineScope.launch { + appSettingsStore.setDefaultScanDpi(selectedValue) + } + } + ) + // Timeout setting UIntSetting( { diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 0e695d25..964f9a13 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -496,6 +496,21 @@ fun ScanningScreen( exportAlpha, onDismiss = { scanningViewModel.setShowExportOptionsPopup(false) }, updateWidth = { exportOptionsWidth = it }, + onExportOcrPdf = { + scanningViewModel.setShowExportOptionsPopup(false) + scanningViewModel.doPdfExportWithOcr( + context, + { error -> + snackBarError( + error, + scope, + context, + snackbarHostState, + false + ) + } + ) + }, onExportPdf = { scanningViewModel.setShowExportOptionsPopup(false) scanningViewModel.doPdfExport( @@ -535,7 +550,23 @@ fun ScanningScreen( saveOptionsWidth, saveOptionsAlpha, onDismiss = { scanningViewModel.setShowSaveOptionsPopup(false) }, - updateWidth = { exportOptionsWidth = it }, + updateWidth = { saveOptionsWidth = it }, + onExportOcrPdf = { + scanningViewModel.setShowSaveOptionsPopup(false) + scanningViewModel.doPdfExportWithOcr( + context, + { error -> + snackBarError( + error, + scope, + context, + snackbarHostState, + false + ) + }, + saveFileLauncher + ) + }, onExportPdf = { scanningViewModel.setShowSaveOptionsPopup(false) scanningViewModel.doPdfExport( diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 1d122b68..45a46050 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -42,7 +42,7 @@ import io.github.chrisimx.esclkt.threeHundredthsOfInch import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.services.LocaleProvider import io.github.chrisimx.scanbridge.util.derived -import io.github.chrisimx.scanbridge.util.getMaxResolution +import io.github.chrisimx.scanbridge.util.pickClosestResolution import io.github.chrisimx.scanbridge.util.toDoubleLocalized import java.util.* import kotlinx.coroutines.CoroutineScope @@ -325,9 +325,10 @@ class ScanSettingsComposableStateHolder( !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) val replacementResolution = if (invalidResolutionSetting) { - val highestScanResolution = uiState.capabilities.getMaxResolution(inputSource) + val preferredDpi = maxOf(xRes, yRes) + val closestResolution = pickClosestResolution(supportedResolutions, preferredDpi) - Pair(highestScanResolution.xResolution, highestScanResolution.yResolution) + Pair(closestResolution?.xResolution, closestResolution?.yResolution) } else { Pair(xRes, yRes) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 3d88cc57..bb76421c 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -30,14 +30,20 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import androidx.room.withTransaction +import com.itextpdf.io.font.constants.StandardFonts import com.itextpdf.io.image.ImageDataFactory import com.itextpdf.kernel.geom.PageSize +import com.itextpdf.kernel.font.PdfFontFactory import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image +import com.itextpdf.layout.element.Paragraph +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions import getTrustAllTM import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.ScanRegion import io.github.chrisimx.esclkt.ScanSettings @@ -56,11 +62,16 @@ import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.db.entities.Session import io.github.chrisimx.scanbridge.db.entities.TempFile import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull +import io.github.chrisimx.scanbridge.proto.defaultScanDpiOrNull import io.github.chrisimx.scanbridge.services.ScanJobRepository import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore import io.github.chrisimx.scanbridge.util.calculateDefaultESCLScanSettingsState +import io.github.chrisimx.scanbridge.util.getClosestResolution import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.getMaxResolution +import io.github.chrisimx.scanbridge.util.pickClosestResolution +import io.github.chrisimx.scanbridge.util.recognizePageText +import io.github.chrisimx.scanbridge.util.resolveDefaultScanDpiPreference import io.github.chrisimx.scanbridge.util.rotateBy90 import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage @@ -317,6 +328,9 @@ class ScanningScreenViewModel( suspend fun setScannerCapabilities(caps: ScannerCapabilities) { _scanningScreenData.capabilities.value = caps val storedSession = sessionDao.getSessionById(sessionID) + val defaultScanDpi = resolveDefaultScanDpiPreference( + application.appSettingsStore.data.first().defaultScanDpiOrNull?.value?.toUInt() + ) Timber.d("Stored session: $storedSession") @@ -380,6 +394,12 @@ class ScanningScreenViewModel( validatedInputSource ?: caps.getInputSourceOptions().first(), duplex ?: false ) + val supportedResolutions = selectedInputSourceCaps + .settingProfiles + .firstOrNull() + ?.supportedResolutions + ?.discreteResolutions + .orEmpty() val intent = if (savedSettings.intent != null && !selectedInputSourceCaps.supportedIntents.contains(savedSettings.intent) @@ -439,20 +459,46 @@ class ScanningScreenViewModel( null } + val savedXResolution = savedSettings.xResolution + val savedYResolution = savedSettings.yResolution + val preferredResolution = when { + savedXResolution != null && savedYResolution != null -> { + val savedResolution = DiscreteResolution(savedXResolution, savedYResolution) + if (supportedResolutions.contains(savedResolution)) { + savedResolution + } else { + pickClosestResolution( + supportedResolutions, + maxOf(savedXResolution, savedYResolution) + ) + } + } + + else -> { + if (defaultScanDpi == null) { + supportedResolutions.maxByOrNull { it.xResolution * it.yResolution } + } else { + pickClosestResolution(supportedResolutions, defaultScanDpi) + } + } + } ?: caps.getClosestResolution(validatedInputSource ?: caps.getInputSourceOptions().first(), defaultScanDpi) + val validatedSettings = savedSettings.copy( inputSource = validatedInputSource, duplex = duplex, intent = intent, - scanRegions = scanRegion + scanRegions = scanRegion, + xResolution = preferredResolution.xResolution, + yResolution = preferredResolution.yResolution ) validatedSettings } catch (e: Exception) { Timber.e(e, "Error applying saved settings, using defaults") - caps.calculateDefaultESCLScanSettingsState() + caps.calculateDefaultESCLScanSettingsState(defaultScanDpi) } } else { - caps.calculateDefaultESCLScanSettingsState() + caps.calculateDefaultESCLScanSettingsState(defaultScanDpi) } sessionDao.insertAll(Session(sessionID, initialSettings, savedSettingsUiState)) @@ -551,14 +597,25 @@ class ScanningScreenViewModel( fun doPdfExport(context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null) { viewModelScope.launch { - doPdfExportInternal(context, onError, saveFileLauncher) + doPdfExportInternal(context, onError, saveFileLauncher, false) } } - private suspend fun doPdfExportInternal( + fun doPdfExportWithOcr( context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null + ) { + viewModelScope.launch { + doPdfExportInternal(context, onError, saveFileLauncher, true) + } + } + + private suspend fun doPdfExportInternal( + context: Context, + onError: (String) -> Unit, + saveFileLauncher: ActivityResultLauncher? = null, + withOcr: Boolean ) { val currentScans = scannedPages.value val scannerCapsNullable = scanningScreenData.capabilities @@ -578,14 +635,14 @@ class ScanningScreenViewModel( return } - setLoadingText(R.string.exporting) + setLoadingText(if (withOcr) R.string.exporting_with_ocr else R.string.exporting) val parentDir = File(application.filesDir, "exports") if (!parentDir.exists()) { parentDir.mkdir() } - val nameRoot = "pdfexport-${ + val nameRoot = "${if (withOcr) "pdfocr-export" else "pdfexport"}-${ LocalDateTime.now() .format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH_mm_ss_SSS")) }" @@ -601,63 +658,97 @@ class ScanningScreenViewModel( val chunks = currentScans.chunked(chunkSize) - chunks.forEachIndexed { index, chunk -> - val pdfFile = File( - parentDir, - "$nameRoot-$index.pdf" - ) - PdfWriter(pdfFile).use { writer -> - PdfDocument(writer).use { pdf -> - Document(pdf).use { document -> - chunk.forEachIndexed { i, scan -> - val scanRegion = - scan.originalScanSettings.scanRegions?.regions?.first() ?: ScanRegion( - 297.millimeters().toThreeHundredthsOfInch(), - 210.millimeters().toThreeHundredthsOfInch(), - 0.threeHundredthsOfInch(), - 0.threeHundredthsOfInch() - ) - - val imageData = ImageDataFactory.create(scan.filePath) - - val rotated = scan.rotation == ScanRelativeRotation.Rotated - - val inputSource = scan.originalScanSettings.inputSource ?: InputSource.Platen - - val fallbackResolution = scannerCaps.getMaxResolution(inputSource) - val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution - val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution - - val rotationCorrectedXRes = if (rotated) scannerYResolution else scannerXResolution - val rotationCorrectedYRes = if (rotated) scannerXResolution else scannerYResolution - - // pts are 1/72th inch - val widthPts = (imageData.width / rotationCorrectedXRes.toFloat()).inches().toPoints().value - val heightPts = (imageData.height / rotationCorrectedYRes.toFloat()).inches().toPoints().value + val digitsNeeded = chunks.size.toString().length + val recognizer = if (withOcr) { + TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + } else { + null + } - pdf.addNewPage( - PageSize( - widthPts.toFloat(), - heightPts.toFloat() + try { + chunks.forEachIndexed { index, chunk -> + val pdfFile = File( + parentDir, + "$nameRoot-${index.toString().padStart(digitsNeeded, '0')}.pdf" + ) + PdfWriter(pdfFile).use { writer -> + PdfDocument(writer).use { pdf -> + Document(pdf).use { document -> + val ocrFont = if (withOcr) PdfFontFactory.createFont(StandardFonts.HELVETICA) else null + + chunk.forEachIndexed { i, scan -> + val scanRegion = + scan.originalScanSettings.scanRegions?.regions?.first() ?: ScanRegion( + 297.millimeters().toThreeHundredthsOfInch(), + 210.millimeters().toThreeHundredthsOfInch(), + 0.threeHundredthsOfInch(), + 0.threeHundredthsOfInch() + ) + + val imageData = ImageDataFactory.create(scan.filePath) + + val rotated = scan.rotation == ScanRelativeRotation.Rotated + + val inputSource = scan.originalScanSettings.inputSource ?: InputSource.Platen + + val fallbackResolution = scannerCaps.getMaxResolution(inputSource) + val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution + val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution + + val rotationCorrectedXRes = if (rotated) scannerYResolution else scannerXResolution + val rotationCorrectedYRes = if (rotated) scannerXResolution else scannerYResolution + + // pts are 1/72th inch + val widthPts = (imageData.width / rotationCorrectedXRes.toFloat()).inches().toPoints().value + val heightPts = (imageData.height / rotationCorrectedYRes.toFloat()).inches().toPoints().value + + val pageWidth = widthPts.toFloat() + val pageHeight = heightPts.toFloat() + val pageNumber = i + 1 + + pdf.addNewPage( + PageSize( + pageWidth, + pageHeight + ) ) - ) - val imageElem = Image(imageData) - imageElem.setFixedPosition(i + 1, 0f, 0f) - imageElem.setHeight(heightPts.toFloat()) - imageElem.setWidth(widthPts.toFloat()) + if (withOcr && recognizer != null && ocrFont != null) { + val recognizedPage = recognizePageText(File(scan.filePath), recognizer) + recognizedPage.lines.forEach { line -> + val x = (pageWidth * line.leftRatio).coerceIn(0f, pageWidth) + val y = (pageHeight * line.bottomRatio).coerceIn(0f, pageHeight) + val availableWidth = (pageWidth * line.widthRatio).coerceAtLeast(1f) + val fontSize = (pageHeight * line.heightRatio).coerceIn(6f, 48f) + + document.add( + Paragraph(line.text) + .setFont(ocrFont) + .setFontSize(fontSize) + .setMargin(0f) + .setFixedPosition(pageNumber, x, y, availableWidth) + ) + } + } - document.add(imageElem) + val imageElem = Image(imageData) + imageElem.setFixedPosition(pageNumber, 0f, 0f) + imageElem.setHeight(pageHeight) + imageElem.setWidth(pageWidth) - pageCounter++ - Timber.d("Added page $pageCounter to PDF") + document.add(imageElem) + + pageCounter++ + Timber.d("Added page $pageCounter to PDF") + } } } } } + } finally { + recognizer?.close() } - val digitsNeeded = chunks.size.toString().length val tempPdfFiles = List(chunks.size) { index -> File(parentDir, "$nameRoot-${index.toString().padStart(digitsNeeded, '0')}.pdf") } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt index 8ca88fc2..387d4ad5 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt @@ -29,6 +29,7 @@ val Context.appSettingsStore: DataStore by dataStore( } writeDebug = sharedPrefs.getBoolean("write_debug", false) scanningResponseTimeout = UInt32Value.of(sharedPrefs.getInt("scanning_response_timeout", 25)) + defaultScanDpi = UInt32Value.of(sharedPrefs.getInt("default_scan_dpi", 300)) chunkSizePdfExport = UInt32Value.of(sharedPrefs.getInt("chunk_size_pdf_export", 50)) rememberScanSettings = BoolValue.of(sharedPrefs.getBoolean("remember_scan_settings", true)) }.build() diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt index 64f97cc0..6597f8c5 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt @@ -13,6 +13,7 @@ import java.io.OutputStream object ScanBridgeSettingsSerializer : Serializer { override val defaultValue: ScanBridgeSettings = scanBridgeSettings { scanningResponseTimeout = UInt32Value.of(25) + defaultScanDpi = UInt32Value.of(300) rememberScanSettings = BoolValue.of(true) chunkSizePdfExport = UInt32Value.of(50) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt index 3c8e4827..24ad3c30 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt @@ -29,6 +29,12 @@ suspend fun DataStore.setTimeoutSetting(value: UInt) { } } +suspend fun DataStore.setDefaultScanDpi(value: UInt) { + this.updateSettings { + defaultScanDpi = UInt32Value.of(value.toInt()) + } +} + suspend fun DataStore.setChunkSize(value: UInt) { this.updateSettings { chunkSizePdfExport = UInt32Value.of(value.toInt()) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt index 8ac9b89a..97dfdaa1 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt @@ -1,10 +1,15 @@ package io.github.chrisimx.scanbridge.uicomponents +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,6 +29,7 @@ fun ExportSettingsPopup( alpha: Float, onDismiss: () -> Unit, updateWidth: (Int) -> Unit, + onExportOcrPdf: () -> Unit, onExportPdf: () -> Unit, onExportArchive: () -> Unit ) { @@ -45,20 +51,50 @@ fun ExportSettingsPopup( }, shape = RoundedCornerShape(30.dp) ) { - Row { - IconButton(onClick = { onExportPdf() }) { - Icon( - painterResource(R.drawable.baseline_picture_as_pdf_24), - contentDescription = stringResource(R.string.export_pdf) - ) - } - IconButton(onClick = { onExportArchive() }) { - Icon( - painterResource(R.drawable.baseline_image_24), - contentDescription = stringResource(R.string.export_as_archive) - ) - } + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ExportActionButton( + iconRes = R.drawable.baseline_description_24, + label = stringResource(R.string.export_pdf_with_ocr_short), + contentDescription = stringResource(R.string.export_pdf_with_ocr), + onClick = onExportOcrPdf + ) + ExportActionButton( + iconRes = R.drawable.baseline_picture_as_pdf_24, + label = stringResource(R.string.export_pdf_short), + contentDescription = stringResource(R.string.export_pdf), + onClick = onExportPdf + ) + ExportActionButton( + iconRes = R.drawable.baseline_image_24, + label = stringResource(R.string.export_archive_short), + contentDescription = stringResource(R.string.export_as_archive), + onClick = onExportArchive + ) } } } } + +@Composable +private fun ExportActionButton( + iconRes: Int, + label: String, + contentDescription: String, + onClick: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton(onClick = onClick) { + Icon( + painterResource(iconRes), + contentDescription = contentDescription + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall + ) + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/DefaultScanDpiPreferenceUtil.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/DefaultScanDpiPreferenceUtil.kt new file mode 100644 index 00000000..6a43e500 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/DefaultScanDpiPreferenceUtil.kt @@ -0,0 +1,25 @@ +package io.github.chrisimx.scanbridge.util + +const val DEFAULT_SCAN_DPI_MAX = 0u + +val defaultScanDpiPresets = listOf(150u, 300u, 600u, DEFAULT_SCAN_DPI_MAX) + +fun normalizeDefaultScanDpiPreference(value: UInt?): UInt { + val resolvedValue = value ?: 300u + if (resolvedValue == DEFAULT_SCAN_DPI_MAX) { + return DEFAULT_SCAN_DPI_MAX + } + + return defaultScanDpiPresets + .filterNot { it == DEFAULT_SCAN_DPI_MAX } + .minBy { kotlin.math.abs(it.toLong() - resolvedValue.toLong()) } +} + +fun resolveDefaultScanDpiPreference(value: UInt?): UInt? { + val normalizedValue = normalizeDefaultScanDpiPreference(value) + return if (normalizedValue == DEFAULT_SCAN_DPI_MAX) { + null + } else { + normalizedValue + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/DocumentOcrUtil.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/DocumentOcrUtil.kt new file mode 100644 index 00000000..68dec513 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/DocumentOcrUtil.kt @@ -0,0 +1,104 @@ +package io.github.chrisimx.scanbridge.util + +import android.graphics.BitmapFactory +import com.google.android.gms.tasks.Task +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognizer +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +private const val OCR_TARGET_MAX_SIDE_PX = 4000 + +data class RecognizedOcrLine( + val text: String, + val leftRatio: Float, + val bottomRatio: Float, + val heightRatio: Float, + val widthRatio: Float +) + +data class RecognizedOcrPage( + val lines: List +) + +suspend fun recognizePageText(file: File, recognizer: TextRecognizer): RecognizedOcrPage = withContext(Dispatchers.Default) { + val boundsOnlyOptions = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, boundsOnlyOptions) + + val sampledBitmapOptions = BitmapFactory.Options().apply { + inSampleSize = calculateOcrSampleSize( + boundsOnlyOptions.outWidth, + boundsOnlyOptions.outHeight + ) + } + + val bitmap = BitmapFactory.decodeFile(file.absolutePath, sampledBitmapOptions) + ?: return@withContext RecognizedOcrPage(emptyList()) + + try { + val image = InputImage.fromBitmap(bitmap, 0) + val recognizedText = recognizer.process(image).await() + val width = bitmap.width.toFloat().coerceAtLeast(1f) + val height = bitmap.height.toFloat().coerceAtLeast(1f) + + RecognizedOcrPage( + recognizedText.textBlocks.flatMap { block -> + block.lines.mapNotNull { line -> + val boundingBox = line.boundingBox ?: return@mapNotNull null + val trimmedText = line.text.trim() + if (trimmedText.isEmpty()) { + return@mapNotNull null + } + + RecognizedOcrLine( + text = trimmedText, + leftRatio = boundingBox.left / width, + bottomRatio = (height - boundingBox.bottom) / height, + heightRatio = boundingBox.height() / height, + widthRatio = boundingBox.width() / width + ) + } + } + ) + } finally { + bitmap.recycle() + } +} + +private fun calculateOcrSampleSize(width: Int, height: Int): Int { + val longestSide = maxOf(width, height) + if (longestSide <= OCR_TARGET_MAX_SIDE_PX || longestSide <= 0) { + return 1 + } + + var sampleSize = 1 + while ((longestSide / sampleSize) > OCR_TARGET_MAX_SIDE_PX) { + sampleSize *= 2 + } + + return sampleSize +} + +private suspend fun Task.await(): T = suspendCancellableCoroutine { continuation -> + addOnSuccessListener { result -> + if (continuation.isActive) { + continuation.resume(result) + } + } + addOnFailureListener { exception -> + if (continuation.isActive) { + continuation.resumeWithException(exception) + } + } + addOnCanceledListener { + if (continuation.isActive) { + continuation.cancel() + } + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index e4c07d37..fc3d9cd5 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -88,10 +88,39 @@ fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): ColorModeEnu return chosenColorMode } -fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): ScanSettings { +fun pickClosestResolution(resolutions: List, preferredDpi: UInt): DiscreteResolution? = resolutions.minWithOrNull( + compareBy( + { + kotlin.math.abs(it.xResolution.toLong() - preferredDpi.toLong()) + + kotlin.math.abs(it.yResolution.toLong() - preferredDpi.toLong()) + }, + { + if (it.xResolution <= preferredDpi && it.yResolution <= preferredDpi) { + 0 + } else { + 1 + } + }, + { it.xResolution.toLong() * it.yResolution.toLong() } + ) +) + +fun ScannerCapabilities.getClosestResolution(inputSource: InputSource, preferredDpi: UInt?): DiscreteResolution { + val inputCaps = this.getInputSourceCaps(inputSource) + if (preferredDpi == null) { + return getMaxResolution(inputSource) + } + + return pickClosestResolution( + inputCaps.settingProfiles.first().supportedResolutions.discreteResolutions, + preferredDpi + ) ?: getMaxResolution(inputSource) +} + +fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(preferredDpi: UInt? = 300u): ScanSettings { val inputSource = this.getInputSourceOptions().firstOrNull() ?: InputSource.Platen - val maxResolution = getMaxResolution(inputSource) + val defaultResolution = getClosestResolution(inputSource, preferredDpi) val inputSourceCaps = this.getInputSourceCaps(inputSource, false) @@ -106,8 +135,8 @@ fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): ScanSettings { version = this.interfaceVersion, inputSource = inputSource, scanRegions = maxScanRegion, - xResolution = maxResolution.xResolution, - yResolution = maxResolution.yResolution, + xResolution = defaultResolution.xResolution, + yResolution = defaultResolution.yResolution, colorMode = bestColorMode, documentFormatExt = "image/jpeg" ) diff --git a/app/src/main/proto/app_settings.proto b/app/src/main/proto/app_settings.proto index fa5eee47..eb61d1f8 100644 --- a/app/src/main/proto/app_settings.proto +++ b/app/src/main/proto/app_settings.proto @@ -15,4 +15,5 @@ message ScanBridgeSettings { google.protobuf.StringValue last_used_scan_settings = 7; google.protobuf.StringValue last_used_scan_settings_ui_state = 9; bool migrated_session_files = 8; -} \ No newline at end of file + google.protobuf.UInt32Value default_scan_dpi = 10; +} diff --git a/app/src/main/res/drawable/baseline_description_24.xml b/app/src/main/res/drawable/baseline_description_24.xml new file mode 100644 index 00000000..c55089e9 --- /dev/null +++ b/app/src/main/res/drawable/baseline_description_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a56588bb..306a023f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -72,7 +72,11 @@ Gib bitte eine URL ein Benutzerdefinierter Scanner Als PDF exportieren + PDF + Als OCR-PDF exportieren + OCR-PDF Als JPEG-Archiv exportieren + JPEG-ZIP Keine weiteren Seiten. %1$s Fehler beim Herunterladen des empfangenen Bilds als Datei: %1$s Empfangenes Bild konnte nicht dekodiert werden. %1$s @@ -85,6 +89,9 @@ Debugprotokoll exportieren Diese Einstellung legt fest wie lange ScanBridge maximal auf eine Antwort vom Scanner wartet bevor es den Scanversuch abbricht.\n\nDiese Einstellung ist für langsame Scanner und große Scanbereiche nützlich. Timeoutlänge für Scannerantwort (in Sekunden) + Standard-Scanauflösung (DPI) + Lege fest, mit welchem DPI-Preset neue Scansitzungen starten sollen, wenn gemerkte Scaneinstellungen deaktiviert sind.\n\nDas Preset Max verwendet immer die höchste vom aktuellen Scanner unterstützte Auflösung. + Max Erweiterte Einstellungen Name @@ -96,10 +103,11 @@ HTTPS-Zertifikatsüberprüfung umgehen Diese Einstellung deaktiviert alle Zertifikatsprüfungen bei HTTPS-Verbindungen. Jedes Zertifikat wird folglich akzeptiert, egal ob es ungültig oder selbstsigniert ist. Diese Einstellung sollte nicht in unsicheren Netwerken verwendet werden, denn durch sie wird ein Man-in-the-Middle-Angriff leicht durchführbar.\n\nIn den meisten Fällen werden Scanner in privaten Netzwerken oder über VPN-Verbindungen verwendet, wo Authentizität in der Regel keine große Rolle spielt. In solchen Umgebungen ist es unwahrscheinlich, dass das Deaktivieren der Zertifikatsprüfung ein echtes Sicherheitsrisiko darstellt. Und falls Vertraulichkeit oder Authentizität doch eine Rolle spielen, hilft selbst HTTPS nur begrenzt – denn eSCL bietet keinerlei Client-Authentifizierung. Jeder mit Netzwerkzugriff auf den Scanner kann potenziell auf gescannte Seiten zugreifen. Speichern der zuletzt verwendeten Scaneinstellungen - Wenn diese Option aktiviert ist, werden die zuletzt verwendeten Scaneinstellungen (z.B. Quelle, Duplex usw.) beim Erstellen einer neuen Scansitzung wiederhergestellt.\n\nWenn sie deaktiviert ist, beginnt jede Sitzung mit den standardmäßigen Scaneinstellungen (höchste Auflösung, größtmöglicher Scanbereich). + Wenn diese Option aktiviert ist, werden die zuletzt verwendeten Scaneinstellungen (z.B. Quelle, Duplex usw.) beim Erstellen einer neuen Scansitzung wiederhergestellt.\n\nWenn sie deaktiviert ist, beginnt jede Sitzung mit der konfigurierten Standard-DPI und dem größtmöglichen Scanbereich. Für ScanBridge spenden Den Quellcode anschauen In Datei speichern + OCR wird ausgefuehrt und PDF exportiert... Bild wird geladen… Seite zuschneiden Zuschneiden @@ -124,4 +132,4 @@ Benutzerdefinierten Scanner bearbeiten Willst du den benutzerdefinierten Scanner wirklich löschen? Dieser Vorgang ist unwiderruflich. Benutzerdefinierten Scanner löschen - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7ebb1aa4..69bf87a2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -83,6 +83,9 @@ Esporta registro di debug Questo timeout indica per quanto tempo ScanBridge aspetta la risposta dello scanner prima di interrompere la scansione.\n\nÈ particolarmente utile quando si usano scanner lenti o si devono scansionare aree molto grandi, che richiedono più tempo per la scansione. Timeout di risposta (in secondi) + Risoluzione di scansione predefinita (DPI) + Scegli con quale preset DPI devono iniziare le nuove sessioni di scansione quando le impostazioni ricordate sono disattivate.\n\nIl preset Max usa sempre la risoluzione piu alta supportata dallo scanner corrente. + Max Impostazioni avanzate Nome @@ -94,7 +97,7 @@ Disabilita controlli certificati Questa opzione disabilita la convalida di tutti i certificati HTTPS. Verrà accettato qualsiasi certificato, inclusi quelli autofirmati, scaduti o non validi. Non utilizzare questa opzione su reti pubbliche o non sicure, poiché rende possibili attacchi man-in-the-middle (MitM). Nella maggior parte dei casi, gli scanner vengono utilizzati su reti private o tramite connessioni VPN, dove le criticità sull\'autenticazione sono minime. In tali ambienti, è improbabile che la disabilitazione dei controlli dei certificati rappresenti un rischio reale per la sicurezza. E nel caso in cui la riservatezza o l\'autenticità siano un problema, anche HTTPS non sarà di grande aiuto, poiché eSCL non dispone di autenticazione client. Chiunque abbia accesso alla rete può potenzialmente recuperare le pagine scansionate. Ricorda le impostazioni di scansione - Se abilitata, quando si crea una nuova sessione di scansione verranno ripristinate le ultime impostazioni di scansione utilizzate (sorgente di input, duplex, ecc.). Se disabilitata, ogni sessione inizia con le impostazioni di scansione predefinite (massima risoluzione, area di scansione più ampia possibile). + Se abilitata, quando si crea una nuova sessione di scansione verranno ripristinate le ultime impostazioni di scansione utilizzate (sorgente di input, duplex, ecc.). Se disabilitata, ogni sessione inizia con la DPI predefinita configurata e la massima area di scansione possibile. Dona a ScanBridge Codice sorgente Annulla @@ -123,4 +126,4 @@ Modifica scanner personalizzato Vuoi davvero eliminare lo scanner personalizzato? Questa operazione è irreversibile. Elimina scanner personalizzato - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d72524c..9ea1a419 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,7 +72,11 @@ Please enter an URL Custom scanner Export as PDF + PDF + Export as searchable OCR PDF + OCR PDF Export as archive of JPEGs + JPEG ZIP No further pages. %1$s Error while copying received image to file: %1$s Couldn\'t decode received image. %1$s @@ -85,6 +89,9 @@ Export debug log This timeout determines how long ScanBridge waits at maximum for the scanner to respond to requests until it aborts the scan job.\n\nThis is mostly useful for slow scanners and large scan areas which take longer to scan. Response Timeout (in seconds) + Default scan resolution (DPI) + Choose which DPI preset new scan sessions should start with when remembered scan settings are disabled.\n\nThe Max preset always uses the highest resolution supported by the current scanner. + Max Advanced Settings Name @@ -96,12 +103,13 @@ Disable certificate checks This option disables all HTTPS certificate validation. Any certificate will be accepted, including self-signed, expired, or otherwise invalid ones. Do not use this option on insecure or public networks, as it makes man-in-the-middle (MitM) attacks possible.\n\nIn most cases, scanners are used on private networks or over VPN connections, where concerns about authenticity are minimal. In such environments, disabling certificate checks is unlikely to pose a real security risk. And in case confidentiality or authenticity is a concern, even HTTPS won\'t help much, as eSCL lacks client authentication altogether. Anyone with access to the network can potentially retrieve scanned pages. Remember scan settings - When enabled, your last used scan settings (input source, duplex, etc.) will be restored when you create a new scan session.\n\nWhen disabled, each session starts with default scan settings (highest resolution, largest possible scan area). + When enabled, your last used scan settings (input source, duplex, etc.) will be restored when you create a new scan session.\n\nWhen disabled, each session starts with the configured default DPI and the largest possible scan area. Support development View source code F-Droid Play Store Save to File + Running OCR and exporting PDF... Loading image… Crop current page Crop @@ -126,4 +134,4 @@ Edit custom scanner Do you really want to delete this custom scanner? This action cannot be undone. Delete custom scanner - \ No newline at end of file + diff --git a/app/src/test/java/org/github/chrisimx/scanbridge/DefaultScanDpiPreferenceUtilTest.kt b/app/src/test/java/org/github/chrisimx/scanbridge/DefaultScanDpiPreferenceUtilTest.kt new file mode 100644 index 00000000..6d031a75 --- /dev/null +++ b/app/src/test/java/org/github/chrisimx/scanbridge/DefaultScanDpiPreferenceUtilTest.kt @@ -0,0 +1,23 @@ +package org.github.chrisimx.scanbridge + +import io.github.chrisimx.scanbridge.util.DEFAULT_SCAN_DPI_MAX +import io.github.chrisimx.scanbridge.util.normalizeDefaultScanDpiPreference +import io.github.chrisimx.scanbridge.util.resolveDefaultScanDpiPreference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DefaultScanDpiPreferenceUtilTest { + + @Test + fun normalizeDefaultScanDpiPreference_snapsLegacyValueToNearestPreset() { + assertEquals(300u, normalizeDefaultScanDpiPreference(400u)) + assertEquals(600u, normalizeDefaultScanDpiPreference(700u)) + } + + @Test + fun resolveDefaultScanDpiPreference_mapsMaxSentinelToNull() { + assertNull(resolveDefaultScanDpiPreference(DEFAULT_SCAN_DPI_MAX)) + assertEquals(300u, resolveDefaultScanDpiPreference(300u)) + } +} diff --git a/app/src/test/java/org/github/chrisimx/scanbridge/ScanResolutionPreferenceUtilTest.kt b/app/src/test/java/org/github/chrisimx/scanbridge/ScanResolutionPreferenceUtilTest.kt new file mode 100644 index 00000000..c5d5a952 --- /dev/null +++ b/app/src/test/java/org/github/chrisimx/scanbridge/ScanResolutionPreferenceUtilTest.kt @@ -0,0 +1,36 @@ +package org.github.chrisimx.scanbridge + +import io.github.chrisimx.esclkt.DiscreteResolution +import io.github.chrisimx.scanbridge.util.pickClosestResolution +import org.junit.Assert.assertEquals +import org.junit.Test + +class ScanResolutionPreferenceUtilTest { + + @Test + fun pickClosestResolution_returnsExactMatchWhenAvailable() { + val resolutions = listOf( + DiscreteResolution(200u, 200u), + DiscreteResolution(300u, 300u), + DiscreteResolution(600u, 600u) + ) + + assertEquals( + DiscreteResolution(300u, 300u), + pickClosestResolution(resolutions, 300u) + ) + } + + @Test + fun pickClosestResolution_prefersNearestSupportedValue() { + val resolutions = listOf( + DiscreteResolution(200u, 200u), + DiscreteResolution(600u, 600u) + ) + + assertEquals( + DiscreteResolution(200u, 200u), + pickClosestResolution(resolutions, 300u) + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e546184f..06427955 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ timber = "5.0.1" zoomable = "0.18.0" material3 = "1.5.0-alpha06" materialIcons = "1.7.8" +mlkitTextRecognition = "16.0.1" navigationCompose = "2.9.5" versionsPlugin = "0.53.0" screengrab = "2.1.1" @@ -80,6 +81,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +mlkit-text-recognition = { module = "com.google.mlkit:text-recognition", version.ref = "mlkitTextRecognition" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } screengrab = { module = "tools.fastlane:screengrab", version.ref = "screengrab" } @@ -97,4 +99,4 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } koin = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } -room = { id = "androidx.room", version.ref = "room"} \ No newline at end of file +room = { id = "androidx.room", version.ref = "room"}