From 8677821fa17546ee1406ec6b4ad2daa6d9b61231 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 17 May 2026 15:40:39 +0200 Subject: [PATCH 01/38] Abstract discovery backend --- androidApp/build.gradle.kts | 1 + .../scanbridge/StartupTabDefinitions.kt | 6 +- .../scanbridge/ScanBridgeApplication.kt | 41 +++- .../chrisimx/scanbridge/ScannerBrowser.kt | 79 +++----- .../scanbridge/ScannerDiscoveryBackend.kt | 182 ------------------ .../chrisimx/scanbridge/StartupScreen.kt | 31 +-- .../data/ui/CustomScannerViewModel.kt | 41 ---- .../uicomponents/FoundScannerItem.kt | 96 +++++++-- core/build.gradle.kts | 19 ++ ...PAdapter.kt => InetAddressKMPConverter.kt} | 0 .../RoomBackedCustomScannerRepository.kt | 26 +++ .../escl/EsclScannerDiscoveryBackend.kt | 151 +++++++++++++++ .../scanbridge/escl/EsclScanningProtocol.kt | 99 ++++++++++ .../scanbridge/model/DiscoveredScanner.kt | 8 +- .../chrisimx/scanbridge/model/IpAddress.kt | 2 + .../scanbridge/model/ScannerHandle.kt | 17 ++ .../ports/CustomScannerRepository.kt | 14 ++ .../ports/ScannerDiscoveryBackend.kt | 9 + .../scanbridge/ports/ScanningProtocol.kt | 72 +++++++ .../scannerdiscovery/DiscoveryUsecase.kt | 37 ++++ .../ScannerDiscoveryScreenViewModel.kt | 44 +++++ gradle/libs.versions.toml | 7 +- 22 files changed, 653 insertions(+), 329 deletions(-) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt rename core/src/androidMain/kotlin/{InetAddressKMPAdapter.kt => InetAddressKMPConverter.kt} (100%) create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/CustomScannerRepository.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 3aa83677..4fca9aa5 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -113,6 +113,7 @@ dependencies { implementation(project(":core")) implementation(project(":composeUI")) implementation(libs.androidx.concurrent.futures) + implementation(libs.coil.fetcher.ktor) ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.runtime) diff --git a/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt b/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt index 23822084..b5ad65e8 100644 --- a/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt +++ b/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt @@ -15,14 +15,12 @@ val STARTUP_TABS = listOf( Icons.Filled.Home, Icons.Outlined.Home, true, - { innerPadding, navController, showCustomDialog, setShowCustomDialog, statefulScannerMap, statefulScannerMapSecure -> + { innerPadding, navController, showCustomDialog, setShowCustomDialog -> ScannerBrowser( innerPadding, navController, showCustomDialog, setShowCustomDialog, - statefulScannerMap, - statefulScannerMapSecure ) } ), @@ -32,7 +30,7 @@ val STARTUP_TABS = listOf( Icons.Filled.Settings, Icons.Outlined.Settings, false, - { innerPadding, _, _, _, _, _ -> + { innerPadding, _, _, _, -> AppSettingsScreen(innerPadding) } ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index bd8e67da..462ad6d9 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -1,11 +1,14 @@ package io.github.chrisimx.scanbridge import AndroidHttpClientFactory +import AndroidMdnsDiscoverService import AndroidScanBridgeDbBuilderFactory import android.app.Application import android.content.Context import androidx.datastore.core.DataStore -import io.github.chrisimx.scanbridge.data.ui.CustomScannerViewModel +import coil3.ImageLoader +import coil3.network.ktor3.KtorNetworkFetcherFactory +import io.github.chrisimx.scanbridge.adapters.RoomBackedCustomScannerRepository import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel import io.github.chrisimx.scanbridge.datastore.appSettingsStore @@ -14,22 +17,30 @@ import io.github.chrisimx.scanbridge.db.DefaultScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory +import io.github.chrisimx.scanbridge.escl.EsclScanningProtocol import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.migrations.MigrationExecutor import io.github.chrisimx.scanbridge.migrations.RoomBackedMigrationExecutor import io.github.chrisimx.scanbridge.migrations.migrationsModule +import io.github.chrisimx.scanbridge.model.HttpClientConfig +import io.github.chrisimx.scanbridge.ports.CustomScannerRepository import io.github.chrisimx.scanbridge.ports.HttpClientFactory import io.github.chrisimx.scanbridge.ports.LocaleProvider +import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.ScanningProtocol import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages import io.github.chrisimx.scanbridge.repositories.DatastoreLastRouteRepository import io.github.chrisimx.scanbridge.repositories.DatastoreShownMessagesRepository import io.github.chrisimx.scanbridge.repositories.RoomLastRouteRepository +import io.github.chrisimx.scanbridge.scannerdiscovery.DiscoveryUsecase +import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider import io.github.chrisimx.scanbridge.services.DebugLogService import io.github.chrisimx.scanbridge.services.FileDebugLogService import io.github.chrisimx.scanbridge.services.ScanJobRepository +import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -49,6 +60,25 @@ fun createAppSettingsDataStore(context: Context) = context.appSettingsStore fun createShownMessagesDataStore(context: Context) = context.shownMessagesStore fun createScanBridgeDb(factory: ScanBridgeDbFactory): ScanBridgeDb = factory.createInstance() + +fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): ImageLoader { + val ktorClient: HttpClient = factory.create( + HttpClientConfig( + disableCertValidation = true, + debugLogging = false, + timeoutInSeconds = 2u, + ) + ) + return ImageLoader.Builder(context) + .components { + add( + KtorNetworkFetcherFactory( + httpClient = ktorClient + ) + ) + } + .build() +} val appModule = module { single>(named()) { create(::createShownMessagesDataStore) @@ -71,6 +101,10 @@ val appModule = module { single { create(::createScanBridgeDb) } + single(named("scannerIconImageLoader")) { + create(::createScannerIconImageLoader) + } + factory() bind MdnsDiscoverService::class single() single() bind LastRouteRepository::class single { (scope: CoroutineScope) -> @@ -78,7 +112,10 @@ val appModule = module { } bind ShownMessagesRepository::class factory() viewModel() - viewModel() + single() bind CustomScannerRepository::class + single() + viewModel() + single() bind ScanningProtocol::class } class ScanBridgeApplication : Application() { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index cfb26d07..a09ae7df 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -19,8 +19,6 @@ package io.github.chrisimx.scanbridge -import android.content.Context -import android.net.nsd.NsdManager import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -37,18 +35,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat.getSystemService import androidx.navigation.NavController import io.github.chrisimx.scanbridge.data.model.EditedCustomScanner -import io.github.chrisimx.scanbridge.data.ui.CustomScannerViewModel import io.github.chrisimx.scanbridge.db.entities.CustomScanner +import io.github.chrisimx.scanbridge.escl.EsclScanningProtocol import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel import io.github.chrisimx.scanbridge.uicomponents.FoundScannerItem import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.dialog.CustomScannerDialog @@ -58,37 +56,17 @@ import java.util.* import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import org.koin.androidx.compose.koinViewModel -import timber.log.Timber - -fun startScannerDiscovery( - context: Context, - scannerMap: SnapshotStateMap, - scannerMapSecure: SnapshotStateMap -): Optional>> { - val service = getSystemService(context, NsdManager::class.java) - if (service == null) { - Timber.e("Couldn't get NsdManager service") - return Optional.empty() - } - val listener = ScannerDiscoveryBackend(service, isSecure = false, scannerMap) - val listenerSecure = ScannerDiscoveryBackend(service, isSecure = true, scannerMapSecure) - service.discoverServices("_uscan._tcp", NsdManager.PROTOCOL_DNS_SD, listener) - service.discoverServices("_uscans._tcp", NsdManager.PROTOCOL_DNS_SD, listenerSecure) - Timber.i("Discovery started") - return Optional.of(Pair(service, arrayOf(listener, listenerSecure))) -} +import org.koin.compose.getKoin @Composable fun ScannerList( innerPadding: PaddingValues, navController: NavController, - statefulScannerMap: SnapshotStateMap, - statefulScannerMapSecure: SnapshotStateMap, - customScannerViewModel: CustomScannerViewModel, + customScanners: List, + discoveredScanners: List, setScannerToDelete: (Uuid?) -> Unit, setScannerToEdit: (EditedCustomScanner?) -> Unit ) { - val customScanners by customScannerViewModel.customScanners.collectAsState() LazyColumn( modifier = Modifier @@ -98,25 +76,13 @@ fun ScannerList( verticalArrangement = Arrangement.Center, reverseLayout = true ) { - statefulScannerMap.forEach { - val discoveredScanner = it.value - discoveredScanner.addresses.forEach { - item { - FoundScannerItem(discoveredScanner.name, it, navController) - } - } - } - - statefulScannerMapSecure.forEach { - val discoveredScanner = it.value - discoveredScanner.addresses.forEach { - item { - FoundScannerItem(discoveredScanner.name, it, navController) - } + discoveredScanners.forEach { discoveredScanner -> + item { + FoundScannerItem(discoveredScanner.name, discoveredScanner.iconUrl, discoveredScanner.handle, navController) } } - if (customScanners.isNotEmpty() && statefulScannerMap.isNotEmpty()) { + if (customScanners.isNotEmpty() && discoveredScanners.isNotEmpty()) { item { Text( stringResource(R.string.discovered_scanners), @@ -132,9 +98,11 @@ fun ScannerList( customScanners.forEach { customScanner -> item { + // TODO: Store handles directly instead of converting to UrlHandle here FoundScannerItem( customScanner.name, - customScanner.url.toString(), + null, // No downloaded icon for custom scanners + UrlScannerHandle(getKoin().get(), customScanner.url), navController, { setScannerToDelete(customScanner.uuid) @@ -148,7 +116,7 @@ fun ScannerList( } } - if (customScanners.isNotEmpty() && statefulScannerMap.isNotEmpty()) { + if (customScanners.isNotEmpty() && discoveredScanners.isNotEmpty()) { item { Text( stringResource(R.string.saved_scanners), @@ -167,20 +135,23 @@ fun ScannerBrowser( navController: NavController, currentlyEditedScanner: EditedCustomScanner?, setEditedCustomDialog: (EditedCustomScanner?) -> Unit, - statefulScannerMap: SnapshotStateMap, - statefulScannerMapSecure: SnapshotStateMap ) { - val customScannerViewModel: CustomScannerViewModel = koinViewModel() - val customScanners by customScannerViewModel.customScanners.collectAsState() + val scannerDiscoveryScreenViewModel: ScannerDiscoveryScreenViewModel = koinViewModel() + val customScanners by scannerDiscoveryScreenViewModel.customScanners.collectAsState() + val discoveredScanners by scannerDiscoveryScreenViewModel.discoveredScanners.collectAsState() var deletionScheduledScanner: Uuid? by remember { mutableStateOf(null) } AnimatedContent( - targetState = statefulScannerMap.isNotEmpty() || customScanners.isNotEmpty(), + targetState = discoveredScanners.isNotEmpty() || customScanners.isNotEmpty(), label = "ScannerList" ) { if (it) { - ScannerList(innerPadding, navController, statefulScannerMap, statefulScannerMapSecure, customScannerViewModel, { + ScannerList(innerPadding, + navController, + customScanners, + discoveredScanners, + { deletionScheduledScanner = it }, setEditedCustomDialog) } else { @@ -198,7 +169,7 @@ fun ScannerBrowser( R.string.custom_scanner_deletion_confirmation, onDismiss = { deletionScheduledScanner = null }, onConfirmed = { - customScannerViewModel.deleteScannerByUuid(deletionScheduledScannerImmutable) + scannerDiscoveryScreenViewModel.deleteScannerByUuid(deletionScheduledScannerImmutable) deletionScheduledScanner = null } ) @@ -218,7 +189,7 @@ fun ScannerBrowser( EditedCustomScanner.New -> Uuid.random() } if (save) { - customScannerViewModel.addScanner(CustomScanner(uuid, name, Url(url))) + scannerDiscoveryScreenViewModel.addScanner(CustomScanner(uuid, name, Url(url))) } setEditedCustomDialog(null) if (navigate) { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt deleted file mode 100644 index 48b22c38..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright (C) 2024-2025 Christian Nagel and contributors - * - * This file is part of ScanBridge. - * - * ScanBridge 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. - * - * ScanBridge 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 eSCLKt. - * If not, see . - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package io.github.chrisimx.scanbridge - -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.os.Build -import android.os.ext.SdkExtensions -import androidx.compose.runtime.snapshots.SnapshotStateMap -import io.github.chrisimx.scanbridge.model.DiscoveredScanner -import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol -import io.ktor.http.Url -import io.ktor.http.encodedPath -import java.net.InetAddress -import java.nio.charset.StandardCharsets -import java.util.concurrent.ForkJoinPool -import timber.log.Timber - -private const val TAG = "ScannerDiscovery" - -class ScannerDiscoveryBackend( - val nsdManager: NsdManager, - val isSecure: Boolean, - val statefulScannerMap: SnapshotStateMap -) : NsdManager.DiscoveryListener { - - override fun onDiscoveryStarted(regType: String) { - Timber.i("Service discovery started") - } - - override fun onServiceFound(service: NsdServiceInfo) { - Timber - .d( - "Service (${service.hashCode()}) discovery success ${if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) service.hostAddresses else service.host} ${service.serviceType} ${service.serviceName} ${service.port}" - ) - - val serviceIdentifier = "${service.serviceName}.${service.serviceType}" - if (statefulScannerMap.contains(serviceIdentifier)) { - Timber.d("Ignored service. Got it already") - return - } - if (!isSecure && service.serviceType != "_uscan._tcp.") { - return - } - if (isSecure && service.serviceType != "_uscans._tcp.") { - return - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7) { - val serviceInfoCallback = - object : NsdManager.ServiceInfoCallback { - - override fun onServiceInfoCallbackRegistrationFailed(p0: Int) { - Timber.tag(TAG).d("ServiceInfoCallBack (${this.hashCode()}) Registration failed!!! $p0") - } - - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Timber.tag(TAG).d("Service (${this.hashCode()}) updated! $serviceInfo") - var rs = serviceInfo.attributes["rs"]?.toString(StandardCharsets.UTF_8) ?: "/" - - rs = if (rs.isEmpty()) "/" else "/$rs/" - - val urls = mutableListOf() - - val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - serviceInfo.hostAddresses - } else { - listOf(serviceInfo.host) - } - - for (address in addresses) { - val url = tryParseScannerUrl(address, serviceInfo, rs) ?: continue - - Timber.tag(TAG).d("Built URL: $url with address: ${address.hostAddress}") - urls.add(url.toString()) - } - - statefulScannerMap.put(serviceIdentifier, DiscoveredScanner(serviceInfo.serviceName, urls)) - } - - override fun onServiceLost() { - statefulScannerMap.remove(serviceIdentifier) - nsdManager.unregisterServiceInfoCallback(this) - Timber.tag(TAG).d("Service was lost!") - } - - override fun onServiceInfoCallbackUnregistered() { - Timber.tag(TAG).d("ServiceInfoCallback (${this.hashCode()}) is getting unregistered!") - } - } - nsdManager.registerServiceInfoCallback(service, ForkJoinPool(1), serviceInfoCallback) - } else { - Timber.d("ServiceInfoCallback not supported. Falling back to ResolveListener") - nsdManager.resolveService( - service, - object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Timber.e("Resolve failed: $errorCode") - } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - Timber.tag(TAG).d("Resolve succeeded (${serviceInfo.hashCode()}) updated! $serviceInfo") - var rs = serviceInfo.attributes["rs"]?.toString(StandardCharsets.UTF_8) ?: "/" - - rs = if (rs.isEmpty()) "/" else "/$rs/" - - val address = serviceInfo.host - val url = tryParseScannerUrl(address, serviceInfo, rs) ?: return - - val urls = mutableListOf(url.toString()) - - Timber.tag(TAG).d("Built URL: $url with address: ${address.hostAddress}") - - statefulScannerMap.put(serviceIdentifier, DiscoveredScanner(service.serviceName, urls)) - } - } - ) - } - } - - override fun onServiceLost(service: NsdServiceInfo) { - Timber.i("service ${service.hashCode()} lost: $service") - } - - override fun onDiscoveryStopped(serviceType: String) { - Timber.i("Discovery stopped: $serviceType") - } - - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - Timber.e("Discovery failed: Error code:$errorCode") - nsdManager.stopServiceDiscovery(this) - } - - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - Timber.e("Discovery failed: Error code:$errorCode") - nsdManager.stopServiceDiscovery(this) - } -} - -private fun ScannerDiscoveryBackend.tryParseScannerUrl(address: InetAddress, serviceInfo: NsdServiceInfo, rs: String): Url? { - if (address.isLinkLocalAddress) { - Timber.tag(TAG).d("Ignoring link local address: ${address.hostAddress}") - return null - } - val sanitizedURLHost = address.hostAddress!!.substringBefore('%') - val ipv6FormattedURLHost = if (address.address.size == 16) { - "[$sanitizedURLHost]" - } else { - sanitizedURLHost - } - return try { - val result = URLBuilder().apply { - protocol = if (isSecure) URLProtocol.HTTPS else URLProtocol.HTTP - host = ipv6FormattedURLHost - port = serviceInfo.port - encodedPath = rs - }.build() - Url(result.toString()) // Try to parse it to confirm that no invalid URLs will be shown - result - } catch (e: Exception) { - Timber.tag(TAG).e("Couldn't built address from: ${address.hostAddress} Exception: $e") - null - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt index 76685def..acf85d59 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt @@ -72,8 +72,6 @@ data class StartupScreen( navController: NavController, showCustomDialog: EditedCustomScanner?, setShowCustomDialog: (EditedCustomScanner?) -> Unit, - statefulScannerMap: SnapshotStateMap, - statefulScannerMapSecure: SnapshotStateMap ) -> Unit ) @@ -86,31 +84,6 @@ fun StartupScreen(navController: NavController) { var selectedScreen by rememberSaveable(stateSaver = StartupScreenSaver) { mutableStateOf(INDEXED_TABS.first()) } val unindexedSelectedScreen = selectedScreen.value - val context = LocalContext.current - - val statefulScannerMap = remember { mutableStateMapOf() } - val statefulScannerMapSecure = remember { mutableStateMapOf() } - - DisposableEffect(Unit) { - val discoveryPairOptional = startScannerDiscovery(context, statefulScannerMap, statefulScannerMapSecure) - - if (discoveryPairOptional.isEmpty) { - return@DisposableEffect onDispose { - Timber.e("Couldn't start discovery") - } - } - - val discoveryPair = discoveryPairOptional.get() - - onDispose { - Timber.i("Discovery stopped") - for (d in discoveryPair.second) { - Timber.i("Stopping discovery for ${d.statefulScannerMap}") - discoveryPair.first.stopServiceDiscovery(d) - } - } - } - var showCustomDialog: EditedCustomScanner? by remember { mutableStateOf(null) } Scaffold( @@ -159,9 +132,7 @@ fun StartupScreen(navController: NavController) { innerPadding, navController, showCustomDialog, - { showCustomDialog = it }, - statefulScannerMap, - statefulScannerMapSecure + { showCustomDialog = it } ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt index 5e0d4ecf..e156dc35 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt @@ -18,44 +18,3 @@ */ package io.github.chrisimx.scanbridge.data.ui - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.db.entities.CustomScanner -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -class CustomScannerViewModel(application: Application, appDb: ScanBridgeDb) : AndroidViewModel(application) { - private val customScannerDao = appDb.customScannerDao() - private val _customScanners = customScannerDao.getAllFlow() - val customScanners: StateFlow> = _customScanners - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), listOf()) - - fun addScanner(scanner: CustomScanner) { - viewModelScope.launch { - customScannerDao.insertAll(scanner) - } - } - - suspend fun loadScannerByUuid(scanner: Uuid): CustomScanner? = customScannerDao.getById(scanner) - - @OptIn(ExperimentalUuidApi::class) - fun deleteScanner(scanner: CustomScanner) { - viewModelScope.launch { - customScannerDao.delete(scanner) - } - } - - @OptIn(ExperimentalUuidApi::class) - fun deleteScannerByUuid(scanner: Uuid) { - viewModelScope.launch { - customScannerDao.deleteById(scanner) - } - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index ec00c812..ee3332a4 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt @@ -33,24 +33,66 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import coil3.ImageLoader +import coil3.compose.AsyncImage import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.ScannerRoute +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.ktor.http.Url import java.util.* import kotlin.uuid.Uuid +import org.koin.compose.koinInject +import org.koin.core.qualifier.named import timber.log.Timber +@Composable +fun tintedPainterResource( + id: Int, + tint: Color, +): Painter { + val basePainter = painterResource(id) + + return remember(basePainter, tint) { + object : Painter() { + override val intrinsicSize: Size + get() = basePainter.intrinsicSize + + override fun DrawScope.onDraw() { + with(basePainter) { + draw( + size = size, + colorFilter = ColorFilter.tint(tint) + ) + } + } + } + } +} + @Composable fun FoundScannerItem( name: String, - address: String, + iconUrl: Url?, + handle: ScannerHandle, navController: NavController, deleteScanner: (() -> Unit)? = null, editScanner: (() -> Unit)? = null @@ -62,7 +104,9 @@ fun FoundScannerItem( .padding(10.dp), onClick = { val sessionID = Uuid.random() - navController.navigate(route = ScannerRoute(name, address, sessionID.toString())) + // TODO: Use scanner handle instead of URL + val url = (handle as UrlScannerHandle).url + navController.navigate(route = ScannerRoute(name, url.toString(), sessionID.toString())) } ) { Row( @@ -71,14 +115,42 @@ fun FoundScannerItem( .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - Icon( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(17.dp), - painter = painterResource(R.drawable.round_print_36), - tint = MaterialTheme.colorScheme.surfaceTint, - contentDescription = stringResource(id = R.string.print_symbol_desc) + val tintedPlaceholder = tintedPainterResource( + io.github.chrisimx.scanbridge.R.drawable.round_print_36, + MaterialTheme.colorScheme.surfaceTint + ) + + val imageLoader: ImageLoader = koinInject( + qualifier = named("scannerIconImageLoader") ) + + if (iconUrl != null) { + AsyncImage( + model = iconUrl.toString(), + contentDescription = stringResource(id = R.string.print_symbol_desc), + imageLoader = imageLoader, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(17.dp), + placeholder = tintedPlaceholder, + error = tintedPlaceholder, + onError = { result -> + val throwable = result.result.throwable + throwable.printStackTrace() + + println("Coil image load failed: ${throwable.message}") + } + ) + } else { + Icon( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(17.dp), + painter = painterResource(R.drawable.round_print_36), + tint = MaterialTheme.colorScheme.surfaceTint, + contentDescription = stringResource(id = R.string.print_symbol_desc) + ) + } Column( modifier = Modifier .weight(1f) @@ -93,7 +165,7 @@ fun FoundScannerItem( ) } Text( - address, + "${handle.stringRepresentation} (${handle.protocol.protocolIdentifier})", style = MaterialTheme.typography.labelLarge ) } @@ -102,7 +174,7 @@ fun FoundScannerItem( modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp), onClick = { editScanner.invoke() - Timber.i("Edit button clicked for custom scanner: $name at $address") + Timber.i("Edit button clicked for custom scanner: $name at $handle") } ) { Icon( @@ -115,7 +187,7 @@ fun FoundScannerItem( modifier = Modifier.padding(end = 8.dp, top = 8.dp, bottom = 8.dp), onClick = { deleteScanner.invoke() - Timber.i("Delete button clicked for custom scanner: $name at $address") + Timber.i("Delete button clicked for custom scanner: $name at $handle") } ) { Icon( diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 329bec11..3e5331bf 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -33,12 +33,20 @@ kotlin { org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 ) } + + withDeviceTest { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + execution = "HOST" + } } iosArm64() iosSimulatorArm64() sourceSets { + all { + languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") + } commonMain.dependencies { api("com.diamondedge:logging:2.1.0") api(libs.koin.core) @@ -52,6 +60,8 @@ kotlin { // Room deps implementation(libs.androidx.room.runtime) implementation(libs.androidx.sqlite.bundled) + + api("com.rickclephas.kmp:kmp-observableviewmodel-core:1.0.3") } commonTest.dependencies { @@ -62,6 +72,15 @@ kotlin { api(libs.ktor.client.okhttp) } + getByName("androidDeviceTest") { + dependencies { + implementation(kotlin("test")) + implementation("androidx.test:core-ktx:1.7.0") + implementation("androidx.test.ext:junit-ktx:1.2.1") + implementation("androidx.test:runner:1.7.0") + } + } + jvmMain.dependencies { } } diff --git a/core/src/androidMain/kotlin/InetAddressKMPAdapter.kt b/core/src/androidMain/kotlin/InetAddressKMPConverter.kt similarity index 100% rename from core/src/androidMain/kotlin/InetAddressKMPAdapter.kt rename to core/src/androidMain/kotlin/InetAddressKMPConverter.kt diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt new file mode 100644 index 00000000..7bf49488 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt @@ -0,0 +1,26 @@ +package io.github.chrisimx.scanbridge.adapters + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.CustomScanner +import io.github.chrisimx.scanbridge.ports.CustomScannerRepository +import kotlin.uuid.Uuid +import kotlinx.coroutines.flow.Flow + +class RoomBackedCustomScannerRepository( + appDb: ScanBridgeDb, +) : CustomScannerRepository { + private val customScannerDao = appDb.customScannerDao() + override fun allFlow(): Flow> = customScannerDao.getAllFlow() + + override suspend fun add(scanner: CustomScanner) { + customScannerDao.insertAll(scanner) + } + + override suspend fun getById(id: Uuid): CustomScanner? { + return customScannerDao.getById(id) + } + + override suspend fun deleteById(id: Uuid) { + customScannerDao.deleteById(id) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt new file mode 100644 index 00000000..44620a2c --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -0,0 +1,151 @@ +package io.github.chrisimx.scanbridge.escl + +import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.model.IpAddress +import io.github.chrisimx.scanbridge.model.MdnsService +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend +import io.ktor.http.URLBuilder +import io.ktor.http.URLProtocol +import io.ktor.http.Url +import io.ktor.http.encodedPath +import kotlin.time.measureTimedValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam + +class EsclScannerDiscoveryBackend( + val mdnsDiscoverySecureEscl: MdnsDiscoverService, + val mdnsDiscoveryInsecureEscl: MdnsDiscoverService, + val loggerFactory: ScanBridgeLoggerFactory, + @InjectedParam + val esclScanningProtocol: EsclScanningProtocol, + @InjectedParam + val coroutineScope: CoroutineScope +) : ScannerDiscoveryBackend { + private val _logger = loggerFactory.withClass(this::class) + + private val SECURE_SCANNER_DISCOVER_TYPE = "_uscans._tcp" + private val INSECURE_SCANNER_DISCOVER_TYPE = "_uscan._tcp" + + + @OptIn(ExperimentalCoroutinesApi::class) + override val scanners: StateFlow> + get() = combine(mdnsDiscoveryInsecureEscl.foundServices, mdnsDiscoverySecureEscl.foundServices) { foundServices1, foundServices2 -> + foundServices1.values to foundServices2.values + }.mapLatest { (foundServices1, foundServices2) -> + val allFoundServices = foundServices1 + foundServices2 + mdnsServicesToDiscoveredScanners(allFoundServices) + }.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + + + init { + check(mdnsDiscoverySecureEscl !== mdnsDiscoveryInsecureEscl) { + "The MdnsDiscoverServices for secure and insecure eSCL must be different instances" + } + + mdnsDiscoverySecureEscl.start(SECURE_SCANNER_DISCOVER_TYPE) + mdnsDiscoveryInsecureEscl.start(INSECURE_SCANNER_DISCOVER_TYPE) + + coroutineScope.launch { + try { + awaitCancellation() // Close the discovery backend when the coroutine is canceled + } finally { + close() + } + } + } + + private suspend fun mdnsServicesToDiscoveredScanners(mdnsServices: List): List { + val scannerConnectionSettingsForTesting = ScannerConnectionSettings( + timeoutInSeconds = 3uL, + allowSelfSignedCertificates = true, + ) + + return mdnsServices.mapNotNull { + val mdnsService = it + val scannerName = mdnsService.serviceName + var rs = mdnsService.txtAttributes["rs"]?.decodeToString() ?: "/" + val iconUrlString = mdnsService.txtAttributes["representation"]?.decodeToString() + + rs = if (rs.isEmpty()) "/" else "/$rs/" + + val iconUrl = iconUrlString?.toNullableUrl() + val iconUrlWithResolvedIp = iconUrl?.let { icoUrl -> + URLBuilder(icoUrl) + .apply { + host = mdnsService.addresses.firstOrNull()?.urlHost ?: icoUrl.host + }.build() + } + + val scannerUrls = mdnsService.addresses.mapNotNull { address -> + tryParseScannerUrl(address, mdnsService, rs) + } + + scannerUrls.mapNotNull { url -> + val scannerHandle = UrlScannerHandle(esclScanningProtocol, url) + + val scannerCapabilitiesResult = esclScanningProtocol.capabilitiesFor( + scannerHandle, + scannerConnectionSettingsForTesting + ) + + when (scannerCapabilitiesResult) { + is ScannerCapabilitiesResult.Failure, + is ScannerCapabilitiesResult.InternalBug, + is ScannerCapabilitiesResult.InvalidScannerHandle -> return@mapNotNull null + + else -> {} + } + + DiscoveredScanner(scannerName, iconUrlWithResolvedIp, scannerHandle) + } + }.flatten() + } + + private fun String.toNullableUrl(): Url? { + return runCatching { + Url(this) + }.getOrNull() + } + + private fun tryParseScannerUrl(address: IpAddress, serviceInfo: MdnsService, rs: String): Url? { + if (address.isLinkLocal()) { + _logger.debug { "Ignoring link local address: ${address.text}, url text rep: ${address.urlHost}" } + return null + } + + val isSecure = serviceInfo.serviceType == SECURE_SCANNER_DISCOVER_TYPE + + return try { + val result = URLBuilder().apply { + protocol = if (isSecure) URLProtocol.HTTPS else URLProtocol.HTTP + host = address.urlHost + port = serviceInfo.port + encodedPath = rs + }.build() + Url(result.toString()) // Try to parse it to confirm that no invalid URLs will be shown + result + } catch (e: Exception) { + _logger.error { "Couldn't built address from: ${address.urlHost} Exception: $e" } + null + } + } + + + override fun close() { + mdnsDiscoverySecureEscl.close() + mdnsDiscoveryInsecureEscl.close() + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt new file mode 100644 index 00000000..8ab53f4a --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -0,0 +1,99 @@ +package io.github.chrisimx.scanbridge.escl + +import io.github.chrisimx.esclkt.ESCLHttpCallResult +import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.scanbridge.model.HttpClientConfig +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.ports.HttpClientFactory +import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class EsclScanningProtocol( + val mdnsDiscoverySecureEscl: MdnsDiscoverService, + val mdnsDiscoveryInsecureEscl: MdnsDiscoverService, + private val loggerFactory: ScanBridgeLoggerFactory, + private val httpClientFactory: HttpClientFactory +) : ScanningProtocol { + private val _logger = loggerFactory.withClass(this::class) + + override val protocolIdentifier: String + get() = "eSCL" + override val usesUrls: Boolean + get() = true + + init { + check(mdnsDiscoverySecureEscl !== mdnsDiscoveryInsecureEscl) { + "The MdnsDiscoverServices for secure and insecure eSCL must be different instances" + } + } + + override fun createDiscoveryBackend(coroutineScope: CoroutineScope, settings: ScannerConnectionSettings): ScannerDiscoveryBackend = + EsclScannerDiscoveryBackend( + mdnsDiscoverySecureEscl, + mdnsDiscoveryInsecureEscl, + loggerFactory, + this, + coroutineScope + ) + + override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { + val scannerUrlHandle = + scanner as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) + + val httpConfig = deriveHttpConfigFromConnectionSettings(settings) + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + + val scannerCapsResult = httpClient.use { + esclRequestClient.getScannerCapabilities() + } + + return when (scannerCapsResult) { + is ESCLRequestClient.ScannerCapabilitiesResult.InternalBug -> ScannerCapabilitiesResult.InternalBug(scannerCapsResult.exception) + is ESCLRequestClient.ScannerCapabilitiesResult.RequestFailure -> when (val error = scannerCapsResult.error) { + is ESCLHttpCallResult.Error.UntrustedCertificate -> ScannerCapabilitiesResult.UntrustedCertificate(error.cause) + else -> ScannerCapabilitiesResult.Failure(error) + } + is ESCLRequestClient.ScannerCapabilitiesResult.ScannerCapabilitiesMalformed -> ScannerCapabilitiesResult.ScannerCapsFormatInvalid( + scannerCapsResult.exception, + scannerCapsResult.content + ) + is ESCLRequestClient.ScannerCapabilitiesResult.Success -> ScannerCapabilitiesResult.Success(scannerCapsResult.scannerCapabilities) + } + } + + private fun deriveHttpConfigFromConnectionSettings(settings: ScannerConnectionSettings): HttpClientConfig = HttpClientConfig( + settings.allowSelfSignedCertificates, + settings.debugLogging, + settings.timeoutInSeconds + ) + + override fun executeScanJob( + handle: ScannerHandle, + settings: ScannerConnectionSettings, + jobScanSettings: ScanSettings + ): Flow = flow { + /*val scannerUrlHandle = + handle as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) + + requireNotNull(scannerUrlHandle) { "Scanner handle is not a URL handle" } + + val httpConfig = deriveHttpConfigFromConnectionSettings(settings) + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient)*/ + + emit(ScanJobProcessingEvent.Failure(Exception("Not implemented"))) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt index 4bdbfe85..11aeb966 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt @@ -1,3 +1,9 @@ package io.github.chrisimx.scanbridge.model -data class DiscoveredScanner(val name: String, val addresses: List) +import io.ktor.http.Url + +data class DiscoveredScanner( + val name: String, + val iconUrl: Url?, + val handle: ScannerHandle +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt index 7a25b038..4db3f0f5 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt @@ -8,6 +8,8 @@ sealed interface IpAddress { val urlHost: String get() = text + fun isLinkLocal(): Boolean = false // TODO: Implement link local detection + class V4( override val bytes: ByteArray, ) : IpAddress { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt new file mode 100644 index 00000000..22757d68 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt @@ -0,0 +1,17 @@ +package io.github.chrisimx.scanbridge.model + +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.ktor.http.Url + +sealed interface ScannerHandle { + val protocol: ScanningProtocol + val stringRepresentation: String +} + +data class UrlScannerHandle( + override val protocol: ScanningProtocol, + val url: Url +) : ScannerHandle { + override val stringRepresentation: String + get() = url.toString() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/CustomScannerRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/CustomScannerRepository.kt new file mode 100644 index 00000000..0718330b --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/CustomScannerRepository.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge.ports + +import io.github.chrisimx.scanbridge.db.entities.CustomScanner +import kotlin.uuid.Uuid +import kotlinx.coroutines.flow.Flow + +interface CustomScannerRepository { + fun allFlow(): Flow> + suspend fun add(scanner: CustomScanner) + + suspend fun getById(id: Uuid): CustomScanner? + + suspend fun deleteById(id: Uuid) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt new file mode 100644 index 00000000..148a5b61 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.ports + +import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.model.ScannerHandle +import kotlinx.coroutines.flow.StateFlow + +interface ScannerDiscoveryBackend : AutoCloseable { + val scanners: StateFlow> +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt new file mode 100644 index 00000000..83b88a32 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -0,0 +1,72 @@ +package io.github.chrisimx.scanbridge.ports + +import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +data class ScannerConnectionSettings( + val timeoutInSeconds: ULong = 10uL, + val allowSelfSignedCertificates: Boolean = false, + val debugLogging: Boolean = false, +) + +sealed class ScannerCapabilitiesResult { + data class Success(val scannerCapabilities: ScannerCapabilities) : ScannerCapabilitiesResult() + data class ScannerCapsFormatInvalid(val error: Exception, val scannerCapsContent: String?) : ScannerCapabilitiesResult() + data class UntrustedCertificate(val error: String?) : ScannerCapabilitiesResult() + data class InvalidScannerHandle(val scannerHandle: ScannerHandle) : ScannerCapabilitiesResult() + data class InternalBug(val exception: Exception) : ScannerCapabilitiesResult() + data class Failure(val reason: Any) : ScannerCapabilitiesResult() +} + +sealed class ScanJobProcessingEvent { + data class Update(val progress: Double) : ScanJobProcessingEvent() + data class Success(val scannedPages: ESCLRequestClient.ScannedPage) : ScanJobProcessingEvent() + data class Failure(val reason: Any) : ScanJobProcessingEvent() +} + +interface ScanningProtocol { + /** + * A string that uniquely identifies this protocol (e.g. "eSCL", "WSD", ...) + * Maybe not needed, let's see... + */ + val protocolIdentifier: String + + /** + * Whether this scanning protocol uses URLs for identifying a scanner. + * This flag is needed so that it can be decided for which protocols the manual URL entry + * feature is allowed. + */ + val usesUrls: Boolean + + /** + * Creates a [ScannerDiscoveryBackend] that can be used to find scanners providing + * this scanning protocol. + * + * If the protocol does not support automatic discovery, this can return null. + */ + fun createDiscoveryBackend(coroutineScope: CoroutineScope, settings: ScannerConnectionSettings): ScannerDiscoveryBackend? + + /** + * Retrieves the capabilities of a scanner identified by the given string. + * + * The meaning of the string [scannerIdentifier] is protocol dependent. It could be a URL, + * a PCI device number or something else. This is left free to allow support for protocols that + * do not use URLs. + * Within a [ScanningProtocol] this identifier should have + * consistent meaning. + */ + suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult + + /** + * Executes the scan job on the given scanner. + * + * Each process update is provided as a [ScanJobProcessingEvnt]. This also provides + * the scanned pages if the scanning job succeeds. + */ + fun executeScanJob(handle: ScannerHandle, settings: ScannerConnectionSettings, jobScanSettings: ScanSettings): Flow +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt new file mode 100644 index 00000000..28de65f7 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt @@ -0,0 +1,37 @@ +package io.github.chrisimx.scanbridge.scannerdiscovery + +import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import org.koin.core.component.KoinComponent + +class DiscoveryUsecase( + private val scanningProtocols: List, +) { + fun discoveredScanners( + coroutineScope: CoroutineScope, + connectionSettings: ScannerConnectionSettings = ScannerConnectionSettings(), + ): Flow> { + val discoveryBackends = + scanningProtocols.mapNotNull { protocol -> + protocol.createDiscoveryBackend(coroutineScope, connectionSettings) + } + + if (discoveryBackends.isEmpty()) { + return flowOf(emptyList()) + } + + return combine(discoveryBackends.map { it.scanners }) { scannerLists -> + scannerLists + .flatMap { it.asIterable() } + .distinctBy { it.handle } + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt new file mode 100644 index 00000000..bdd80cb3 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt @@ -0,0 +1,44 @@ +package io.github.chrisimx.scanbridge.scannerdiscovery + +import com.rickclephas.kmp.observableviewmodel.ViewModel +import com.rickclephas.kmp.observableviewmodel.coroutineScope +import com.rickclephas.kmp.observableviewmodel.launch +import com.rickclephas.kmp.observableviewmodel.stateIn +import io.github.chrisimx.scanbridge.db.entities.CustomScanner +import io.github.chrisimx.scanbridge.ports.CustomScannerRepository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow + +class ScannerDiscoveryScreenViewModel( + val customScannerRepo: CustomScannerRepository, + val discoveryUsecase: DiscoveryUsecase +) : ViewModel() { + private val _customScanners = customScannerRepo.allFlow() + + val customScanners: StateFlow> = _customScanners + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val discoveredScanners = discoveryUsecase.discoveredScanners(viewModelScope.coroutineScope) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun addScanner(scanner: CustomScanner) { + viewModelScope.launch { + customScannerRepo.add(scanner) + } + } + + suspend fun loadScannerByUuid(scanner: Uuid): CustomScanner? = customScannerRepo.getById(scanner) + + @OptIn(ExperimentalUuidApi::class) + fun deleteScanner(scanner: CustomScanner) { + deleteScannerByUuid(scanner.uuid) + } + + @OptIn(ExperimentalUuidApi::class) + fun deleteScannerByUuid(scanner: Uuid) { + viewModelScope.launch { + customScannerRepo.deleteById(scanner) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a5a28f9..93292090 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,12 +4,12 @@ compose-multiplatform = "1.10.3" androidx-activityCompose = "1.12.0" agp = "9.1.0" -coilCompose = "3.3.0" +coil = "3.3.0" concurrentFutures = "1.2.0" constraintlayoutCompose = "1.1.1" converterGson = "2.9.0" datastore = "1.2.0" -esclkt = "2.0.6" +esclkt = "2.0.8-SNAPSHOT" escl-mock-server = "1.0.1" itextCore = "9.3.0" kotlin = "2.3.20-Beta1" @@ -94,7 +94,8 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" , version.ref = "materialIcons"} androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-fetcher-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } esclkt = { module = "io.github.chrisimx:esclkt", version.ref = "esclkt" } itext7-core = { module = "com.itextpdf:itext-core", version.ref = "itextCore" } junit = { group = "junit", name = "junit", version.ref = "junit" } From af91ce986e92654097a39e82da5637357c63b006 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 17 May 2026 17:42:01 +0200 Subject: [PATCH 02/38] Refactor HTTP timeout configuration and improve scanner discovery (check for reachability) --- .../scanbridge/ScanBridgeApplication.kt | 4 +- .../data/ui/ScanningScreenViewModel.kt | 8 +- .../kotlin/AndroidHttpClientFactory.kt | 6 +- .../androidMain/kotlin/TimingEventListener.kt | 67 +++++++++++++ .../escl/EsclScannerDiscoveryBackend.kt | 98 ++++++++++++++----- .../scanbridge/escl/EsclScanningProtocol.kt | 4 +- .../scanbridge/model/HttpClientConfig.kt | 8 +- .../scanbridge/ports/ScanningProtocol.kt | 6 +- 8 files changed, 161 insertions(+), 40 deletions(-) create mode 100644 core/src/androidMain/kotlin/TimingEventListener.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 462ad6d9..59b6e64b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -66,7 +66,9 @@ fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): HttpClientConfig( disableCertValidation = true, debugLogging = false, - timeoutInSeconds = 2u, + requestTimeoutInSeconds = 2u, + connectTimeoutInSeconds = 2u, + socketTimeoutInSeconds = 2u, ) ) return ImageLoader.Builder(context) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 1d27c139..f828ba50 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -491,11 +491,7 @@ class ScanningScreenViewModel( sessionID, currentSettings, address, - HttpClientConfig( - certificateValidationDisabled, - withDebugInterceptor, - timeout.toULong() - ) + createHttpClientConfig() ) scanJobRepo.enqueue(scanJob) ScanJobForegroundService.startService(application) @@ -755,6 +751,8 @@ class ScanningScreenViewModel( fun createHttpClientConfig() = HttpClientConfig( certificateValidationDisabled, withDebugInterceptor, + timeout.toULong(), + timeout.toULong(), timeout.toULong() ) diff --git a/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt b/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt index dca1f40f..15586c79 100644 --- a/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt +++ b/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt @@ -12,9 +12,9 @@ class AndroidHttpClientFactory(loggerFactory: ScanBridgeLoggerFactory) : HttpCli override fun create(config: HttpClientConfig): HttpClient = HttpClient(OkHttp) { install(HttpTimeout) { - requestTimeoutMillis = config.timeoutInSeconds.toLong() * 1000 - connectTimeoutMillis = config.timeoutInSeconds.toLong() * 1000 - socketTimeoutMillis = config.timeoutInSeconds.toLong() * 1000 + requestTimeoutMillis = config.requestTimeoutInSeconds.toLong() * 1000 + connectTimeoutMillis = config.connectTimeoutInSeconds.toLong() * 1000 + socketTimeoutMillis = config.socketTimeoutInSeconds.toLong() * 1000 } if (config.debugLogging) { install(Logging) { diff --git a/core/src/androidMain/kotlin/TimingEventListener.kt b/core/src/androidMain/kotlin/TimingEventListener.kt new file mode 100644 index 00000000..81b54562 --- /dev/null +++ b/core/src/androidMain/kotlin/TimingEventListener.kt @@ -0,0 +1,67 @@ +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy +import okhttp3.Call +import okhttp3.EventListener +import okhttp3.Handshake +import okhttp3.Protocol + +class TimingEventListener : EventListener() { + private val startNanos = System.nanoTime() + private var callId: String? = null + + private fun log(event: String) { + val ms = (System.nanoTime() - startNanos) / 1_000_000 + println("HTTP timing: ${ms}ms - [Call: $callId] $event") + } + + override fun callStart(call: Call) { + callId = call.request().url.toString() + log("callStart") + } + + override fun dnsStart(call: Call, domainName: String) { + log("dnsStart $domainName") + } + + override fun dnsEnd(call: Call, domainName: String, inetAddressList: List) { + log("dnsEnd $inetAddressList") + } + + override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) { + log("connectStart $inetSocketAddress") + } + + override fun connectEnd(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?) { + log("connectEnd $protocol") + } + + override fun secureConnectStart(call: Call) { + log("tlsStart") + } + + override fun secureConnectEnd(call: Call, handshake: Handshake?) { + log("tlsEnd") + } + + override fun requestHeadersStart(call: Call) { + log("requestHeadersStart") + } + + override fun responseHeadersStart(call: Call) { + log("responseHeadersStart") + } + + override fun responseBodyEnd(call: Call, byteCount: Long) { + log("responseBodyEnd ${byteCount} bytes") + } + + override fun callFailed(call: Call, ioe: IOException) { + log("callFailed ${ioe::class.simpleName}: ${ioe.message}") + } + + override fun callEnd(call: Call) { + log("callEnd") + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt index 44620a2c..9975a9f8 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -15,8 +15,13 @@ import io.ktor.http.Url import io.ktor.http.encodedPath import kotlin.time.measureTimedValue import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -39,16 +44,41 @@ class EsclScannerDiscoveryBackend( private val SECURE_SCANNER_DISCOVER_TYPE = "_uscans._tcp" private val INSECURE_SCANNER_DISCOVER_TYPE = "_uscan._tcp" + private val isScannerReachableMap = mutableMapOf() + private val capabilityFetchDispatcher = Dispatchers.IO.limitedParallelism(8) @OptIn(ExperimentalCoroutinesApi::class) - override val scanners: StateFlow> - get() = combine(mdnsDiscoveryInsecureEscl.foundServices, mdnsDiscoverySecureEscl.foundServices) { foundServices1, foundServices2 -> + val _scanners: StateFlow> = + combine(mdnsDiscoveryInsecureEscl.foundServices, mdnsDiscoverySecureEscl.foundServices) { foundServices1, foundServices2 -> foundServices1.values to foundServices2.values }.mapLatest { (foundServices1, foundServices2) -> val allFoundServices = foundServices1 + foundServices2 - mdnsServicesToDiscoveredScanners(allFoundServices) + val discoveredScanners = mdnsServicesToDiscoveredScanners(allFoundServices) + + coroutineScope { + discoveredScanners.map { scanner -> + async(capabilityFetchDispatcher) { + val reachableCached = isScannerReachableMap[scanner.handle.stringRepresentation] + if (reachableCached != null) { + _logger.debug { "Using cached reachable status for ${scanner.handle.stringRepresentation} that was $reachableCached" } + return@async scanner to reachableCached + } + + val isReachable = isReachable(scanner) + _logger.debug { "Scanner ${scanner.handle.stringRepresentation} is reachable: $isReachable" } + isScannerReachableMap[scanner.handle.stringRepresentation] = isReachable + scanner to isReachable + } + } + }.awaitAll() + .filter { (scanner, reachable) -> reachable } + .map { (scanner, _) -> scanner } + }.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + override val scanners: StateFlow> + get() = _scanners + init { check(mdnsDiscoverySecureEscl !== mdnsDiscoveryInsecureEscl) { @@ -67,14 +97,10 @@ class EsclScannerDiscoveryBackend( } } - private suspend fun mdnsServicesToDiscoveredScanners(mdnsServices: List): List { - val scannerConnectionSettingsForTesting = ScannerConnectionSettings( - timeoutInSeconds = 3uL, - allowSelfSignedCertificates = true, - ) - - return mdnsServices.mapNotNull { - val mdnsService = it + private fun mdnsServicesToDiscoveredScanners( + mdnsServices: List, + ): List { + return mdnsServices.flatMap { mdnsService -> val scannerName = mdnsService.serviceName var rs = mdnsService.txtAttributes["rs"]?.decodeToString() ?: "/" val iconUrlString = mdnsService.txtAttributes["representation"]?.decodeToString() @@ -86,32 +112,24 @@ class EsclScannerDiscoveryBackend( URLBuilder(icoUrl) .apply { host = mdnsService.addresses.firstOrNull()?.urlHost ?: icoUrl.host - }.build() + } + .build() } val scannerUrls = mdnsService.addresses.mapNotNull { address -> tryParseScannerUrl(address, mdnsService, rs) } - scannerUrls.mapNotNull { url -> + scannerUrls.map { url -> val scannerHandle = UrlScannerHandle(esclScanningProtocol, url) - val scannerCapabilitiesResult = esclScanningProtocol.capabilitiesFor( + DiscoveredScanner( + scannerName, + iconUrlWithResolvedIp, scannerHandle, - scannerConnectionSettingsForTesting ) - - when (scannerCapabilitiesResult) { - is ScannerCapabilitiesResult.Failure, - is ScannerCapabilitiesResult.InternalBug, - is ScannerCapabilitiesResult.InvalidScannerHandle -> return@mapNotNull null - - else -> {} - } - - DiscoveredScanner(scannerName, iconUrlWithResolvedIp, scannerHandle) } - }.flatten() + } } private fun String.toNullableUrl(): Url? { @@ -120,6 +138,34 @@ class EsclScannerDiscoveryBackend( }.getOrNull() } + private suspend fun isReachable( + scanner: DiscoveredScanner, + ): Boolean { + val settings = ScannerConnectionSettings( + connectionTimeoutInSeconds = 3uL, + totalTimeoutInSeconds = 10uL, + debugLogging = true, + allowSelfSignedCertificates = true, + ) + + val result = measureTimedValue { + esclScanningProtocol.capabilitiesFor(scanner.handle, settings) + } + _logger.debug { + "Scanner capabilities for ${scanner.handle.stringRepresentation} took ${result.duration}" + } + + return when (result.value) { + is ScannerCapabilitiesResult.Failure, + is ScannerCapabilitiesResult.InvalidScannerHandle -> { + _logger.debug { "Scanner ${scanner.handle.stringRepresentation} is not reachable. Result ${result.value}" } + false + } + + else -> true + } + } + private fun tryParseScannerUrl(address: IpAddress, serviceInfo: MdnsService, rs: String): Url? { if (address.isLinkLocal()) { _logger.debug { "Ignoring link local address: ${address.text}, url text rep: ${address.urlHost}" } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index 8ab53f4a..ea54f00f 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -76,7 +76,9 @@ class EsclScanningProtocol( private fun deriveHttpConfigFromConnectionSettings(settings: ScannerConnectionSettings): HttpClientConfig = HttpClientConfig( settings.allowSelfSignedCertificates, settings.debugLogging, - settings.timeoutInSeconds + settings.connectionTimeoutInSeconds, + settings.connectionTimeoutInSeconds, + settings.totalTimeoutInSeconds, ) override fun executeScanJob( diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt index 16484b2e..66c74699 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt @@ -1,3 +1,9 @@ package io.github.chrisimx.scanbridge.model -data class HttpClientConfig(val disableCertValidation: Boolean, val debugLogging: Boolean, val timeoutInSeconds: ULong) +data class HttpClientConfig( + val disableCertValidation: Boolean, + val debugLogging: Boolean, + val connectTimeoutInSeconds: ULong, + val socketTimeoutInSeconds: ULong, + val requestTimeoutInSeconds: ULong +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt index 83b88a32..ba186a5a 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -4,12 +4,12 @@ import io.github.chrisimx.esclkt.ESCLRequestClient import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities import io.github.chrisimx.scanbridge.model.ScannerHandle -import io.github.chrisimx.scanbridge.model.UrlScannerHandle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow data class ScannerConnectionSettings( - val timeoutInSeconds: ULong = 10uL, + val connectionTimeoutInSeconds: ULong = 10uL, + val totalTimeoutInSeconds: ULong = 10uL, val allowSelfSignedCertificates: Boolean = false, val debugLogging: Boolean = false, ) @@ -19,7 +19,7 @@ sealed class ScannerCapabilitiesResult { data class ScannerCapsFormatInvalid(val error: Exception, val scannerCapsContent: String?) : ScannerCapabilitiesResult() data class UntrustedCertificate(val error: String?) : ScannerCapabilitiesResult() data class InvalidScannerHandle(val scannerHandle: ScannerHandle) : ScannerCapabilitiesResult() - data class InternalBug(val exception: Exception) : ScannerCapabilitiesResult() + data class InternalBug(val exception: Any) : ScannerCapabilitiesResult() data class Failure(val reason: Any) : ScannerCapabilitiesResult() } From a2d587cef8c56b59fb0a78333ee827a019437057 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 17 May 2026 17:57:11 +0200 Subject: [PATCH 03/38] Show fast scanners quickly and slow scanners later if they are reachable --- .../escl/EsclScannerDiscoveryBackend.kt | 118 +++++++++++++----- 1 file changed, 89 insertions(+), 29 deletions(-) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt index 9975a9f8..792cdff3 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -22,9 +22,12 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -48,33 +51,21 @@ class EsclScannerDiscoveryBackend( private val capabilityFetchDispatcher = Dispatchers.IO.limitedParallelism(8) @OptIn(ExperimentalCoroutinesApi::class) - val _scanners: StateFlow> = - combine(mdnsDiscoveryInsecureEscl.foundServices, mdnsDiscoverySecureEscl.foundServices) { foundServices1, foundServices2 -> - foundServices1.values to foundServices2.values - }.mapLatest { (foundServices1, foundServices2) -> - val allFoundServices = foundServices1 + foundServices2 - val discoveredScanners = mdnsServicesToDiscoveredScanners(allFoundServices) - - coroutineScope { - discoveredScanners.map { scanner -> - async(capabilityFetchDispatcher) { - val reachableCached = isScannerReachableMap[scanner.handle.stringRepresentation] - if (reachableCached != null) { - _logger.debug { "Using cached reachable status for ${scanner.handle.stringRepresentation} that was $reachableCached" } - return@async scanner to reachableCached - } - - val isReachable = isReachable(scanner) - _logger.debug { "Scanner ${scanner.handle.stringRepresentation} is reachable: $isReachable" } - isScannerReachableMap[scanner.handle.stringRepresentation] = isReachable - scanner to isReachable - } - } - }.awaitAll() - .filter { (scanner, reachable) -> reachable } - .map { (scanner, _) -> scanner } - - }.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + private val _scanners: StateFlow> = + combine( + mdnsDiscoveryInsecureEscl.foundServices, + mdnsDiscoverySecureEscl.foundServices, + ) { insecure, secure -> + insecure.values + secure.values + } + .flatMapLatest { allFoundServices -> + discoveredScannersFlow(allFoundServices) + } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + ) override val scanners: StateFlow> get() = _scanners @@ -138,12 +129,81 @@ class EsclScannerDiscoveryBackend( }.getOrNull() } + private fun discoveredScannersFlow( + mdnsServices: List, + ): Flow> = flow { + val discoveredScanners = mdnsServicesToDiscoveredScanners(mdnsServices) + .distinctBy { it.handle.stringRepresentation } + + val alreadyKnownReachable = discoveredScanners.filter { scanner -> + isScannerReachableMap[scanner.handle.stringRepresentation] == true + } + + emit(alreadyKnownReachable) + + val uncheckedScanners = discoveredScanners.filterNot { scanner -> + isScannerReachableMap[scanner.handle.stringRepresentation] == true + } + + val fastReachable = validateScanners( + scanners = uncheckedScanners, + connectionTimeoutSeconds = 1uL, + totalTimeoutSeconds = 2uL, + ) + + fastReachable.forEach { scanner -> + isScannerReachableMap[scanner.handle.stringRepresentation] = true + } + + emit((alreadyKnownReachable + fastReachable).distinctBy { it.handle.stringRepresentation }) + + val slowRetryCandidates = uncheckedScanners - fastReachable.toSet() + + val slowReachable = validateScanners( + scanners = slowRetryCandidates, + connectionTimeoutSeconds = 5uL, + totalTimeoutSeconds = 5uL, + ) + + slowReachable.forEach { scanner -> + isScannerReachableMap[scanner.handle.stringRepresentation] = true + } + + emit( + (alreadyKnownReachable + fastReachable + slowReachable) + .distinctBy { it.handle.stringRepresentation } + ) + } + + private suspend fun validateScanners( + scanners: List, + connectionTimeoutSeconds: ULong, + totalTimeoutSeconds: ULong, + ): List = coroutineScope { + scanners + .map { scanner -> + async(capabilityFetchDispatcher) { + val reachable = isReachable( + scanner = scanner, + connectionTimeoutSeconds = connectionTimeoutSeconds, + totalTimeoutSeconds = totalTimeoutSeconds, + ) + + scanner.takeIf { reachable } + } + } + .awaitAll() + .filterNotNull() + } + private suspend fun isReachable( scanner: DiscoveredScanner, + connectionTimeoutSeconds: ULong, + totalTimeoutSeconds: ULong, ): Boolean { val settings = ScannerConnectionSettings( - connectionTimeoutInSeconds = 3uL, - totalTimeoutInSeconds = 10uL, + connectionTimeoutInSeconds = connectionTimeoutSeconds, + totalTimeoutInSeconds = totalTimeoutSeconds, debugLogging = true, allowSelfSignedCertificates = true, ) From b27b6e33c2b5d9f74c075c34af73bbd1420437f1 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 17 May 2026 18:14:23 +0200 Subject: [PATCH 04/38] More caching of the scanner reachabilities --- .../escl/EsclScannerDiscoveryBackend.kt | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt index 792cdff3..615051d1 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam @@ -135,23 +134,31 @@ class EsclScannerDiscoveryBackend( val discoveredScanners = mdnsServicesToDiscoveredScanners(mdnsServices) .distinctBy { it.handle.stringRepresentation } - val alreadyKnownReachable = discoveredScanners.filter { scanner -> - isScannerReachableMap[scanner.handle.stringRepresentation] == true + val alreadyKnownWithOutcome = discoveredScanners.mapNotNull { scanner -> + scanner to (isScannerReachableMap[scanner.handle.stringRepresentation] ?: return@mapNotNull null) } + val alreadyKnownJustScanners = alreadyKnownWithOutcome.map { (scanner, _) -> scanner } + + val alreadyKnownReachable = alreadyKnownWithOutcome.filter { + (_, reachable) -> reachable + }.map { (scanner, _) -> scanner } emit(alreadyKnownReachable) - val uncheckedScanners = discoveredScanners.filterNot { scanner -> - isScannerReachableMap[scanner.handle.stringRepresentation] == true - } + val uncheckedScanners = discoveredScanners - alreadyKnownJustScanners.toSet() - val fastReachable = validateScanners( + val fastPassResults = validateScanners( scanners = uncheckedScanners, connectionTimeoutSeconds = 1uL, totalTimeoutSeconds = 2uL, ) + val fastReachable = fastPassResults + .filter { (_, isReachable) -> isReachable } + .map { (scanner, _) -> scanner } - fastReachable.forEach { scanner -> + fastPassResults.filter { + (_, isReachable) -> isReachable + }.forEach { (scanner, _) -> isScannerReachableMap[scanner.handle.stringRepresentation] = true } @@ -159,14 +166,17 @@ class EsclScannerDiscoveryBackend( val slowRetryCandidates = uncheckedScanners - fastReachable.toSet() - val slowReachable = validateScanners( + val slowPassResult = validateScanners( scanners = slowRetryCandidates, connectionTimeoutSeconds = 5uL, totalTimeoutSeconds = 5uL, ) + val slowReachable = slowPassResult + .filter { (_, isReachable) -> isReachable } + .map { (scanner, _) -> scanner } - slowReachable.forEach { scanner -> - isScannerReachableMap[scanner.handle.stringRepresentation] = true + slowPassResult.forEach { (scanner, reachable) -> + isScannerReachableMap[scanner.handle.stringRepresentation] = reachable } emit( @@ -179,7 +189,7 @@ class EsclScannerDiscoveryBackend( scanners: List, connectionTimeoutSeconds: ULong, totalTimeoutSeconds: ULong, - ): List = coroutineScope { + ): List> = coroutineScope { scanners .map { scanner -> async(capabilityFetchDispatcher) { @@ -189,11 +199,10 @@ class EsclScannerDiscoveryBackend( totalTimeoutSeconds = totalTimeoutSeconds, ) - scanner.takeIf { reachable } + scanner to reachable } } .awaitAll() - .filterNotNull() } private suspend fun isReachable( From 925e449c6f39c1e96257f6b909c9a65a8ff89a95 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 17 May 2026 22:08:19 +0200 Subject: [PATCH 05/38] Implement incremental upgrading of scanner reachability state and save downloaded scanner caps --- .../escl/EsclScannerDiscoveryBackend.kt | 118 ++++++++---------- .../scanbridge/model/DiscoveredScanner.kt | 4 +- 2 files changed, 54 insertions(+), 68 deletions(-) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt index 615051d1..3c2b5b4c 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -18,20 +18,25 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.InjectedParam +sealed class Optional { + data class Some(val value: T) : Optional() + object None : Optional() +} + class EsclScannerDiscoveryBackend( val mdnsDiscoverySecureEscl: MdnsDiscoverService, val mdnsDiscoveryInsecureEscl: MdnsDiscoverService, @@ -46,7 +51,8 @@ class EsclScannerDiscoveryBackend( private val SECURE_SCANNER_DISCOVER_TYPE = "_uscans._tcp" private val INSECURE_SCANNER_DISCOVER_TYPE = "_uscan._tcp" - private val isScannerReachableMap = mutableMapOf() + private val isScannerReachableMap = mutableMapOf>() + private val isScannerReachableMapMutex = Mutex() private val capabilityFetchDispatcher = Dispatchers.IO.limitedParallelism(8) @OptIn(ExperimentalCoroutinesApi::class) @@ -130,86 +136,64 @@ class EsclScannerDiscoveryBackend( private fun discoveredScannersFlow( mdnsServices: List, - ): Flow> = flow { + ): Flow> = channelFlow { val discoveredScanners = mdnsServicesToDiscoveredScanners(mdnsServices) .distinctBy { it.handle.stringRepresentation } - val alreadyKnownWithOutcome = discoveredScanners.mapNotNull { scanner -> - scanner to (isScannerReachableMap[scanner.handle.stringRepresentation] ?: return@mapNotNull null) - } - val alreadyKnownJustScanners = alreadyKnownWithOutcome.map { (scanner, _) -> scanner } + send(emptyList()) - val alreadyKnownReachable = alreadyKnownWithOutcome.filter { - (_, reachable) -> reachable - }.map { (scanner, _) -> scanner } + discoveredScanners.forEach { scanner -> + launch(capabilityFetchDispatcher) { + _logger.debug { + "Checking reachability of ${scanner.handle.stringRepresentation}" + } - emit(alreadyKnownReachable) - val uncheckedScanners = discoveredScanners - alreadyKnownJustScanners.toSet() + val cachedReachability = isScannerReachableMapMutex.withLock { + isScannerReachableMap[scanner.handle.stringRepresentation] + } - val fastPassResults = validateScanners( - scanners = uncheckedScanners, - connectionTimeoutSeconds = 1uL, - totalTimeoutSeconds = 2uL, - ) - val fastReachable = fastPassResults - .filter { (_, isReachable) -> isReachable } - .map { (scanner, _) -> scanner } - - fastPassResults.filter { - (_, isReachable) -> isReachable - }.forEach { (scanner, _) -> - isScannerReachableMap[scanner.handle.stringRepresentation] = true - } + when (cachedReachability) { + Optional.None -> return@launch - emit((alreadyKnownReachable + fastReachable).distinctBy { it.handle.stringRepresentation }) + is Optional.Some -> { + send(listOf(scanner.copy(scannerCaps = cachedReachability.value))) + return@launch + } - val slowRetryCandidates = uncheckedScanners - fastReachable.toSet() + null -> Unit + } - val slowPassResult = validateScanners( - scanners = slowRetryCandidates, - connectionTimeoutSeconds = 5uL, - totalTimeoutSeconds = 5uL, - ) - val slowReachable = slowPassResult - .filter { (_, isReachable) -> isReachable } - .map { (scanner, _) -> scanner } + val scannerCapabilitiesResult = checkReachabilityAndGetScannerCaps( + scanner = scanner, + connectionTimeoutSeconds = 20u, + totalTimeoutSeconds = 20u, + ) - slowPassResult.forEach { (scanner, reachable) -> - isScannerReachableMap[scanner.handle.stringRepresentation] = reachable - } + isScannerReachableMapMutex.withLock { + isScannerReachableMap[scanner.handle.stringRepresentation] = scannerCapabilitiesResult + ?.let { Optional.Some(it) } + ?: Optional.None + } - emit( - (alreadyKnownReachable + fastReachable + slowReachable) - .distinctBy { it.handle.stringRepresentation } - ) - } + val discoveredScannerWithCaps = scanner.copy(scannerCaps = scannerCapabilitiesResult) - private suspend fun validateScanners( - scanners: List, - connectionTimeoutSeconds: ULong, - totalTimeoutSeconds: ULong, - ): List> = coroutineScope { - scanners - .map { scanner -> - async(capabilityFetchDispatcher) { - val reachable = isReachable( - scanner = scanner, - connectionTimeoutSeconds = connectionTimeoutSeconds, - totalTimeoutSeconds = totalTimeoutSeconds, - ) - - scanner to reachable + if (scannerCapabilitiesResult != null) { + send(listOf(discoveredScannerWithCaps)) } } - .awaitAll() + } } + .runningFold(emptyList()) { current, newlyReachable -> + (current + newlyReachable) + .distinctBy { it.handle.stringRepresentation } + } - private suspend fun isReachable( + private suspend fun checkReachabilityAndGetScannerCaps( scanner: DiscoveredScanner, connectionTimeoutSeconds: ULong, totalTimeoutSeconds: ULong, - ): Boolean { + ): ScannerCapabilitiesResult? { val settings = ScannerConnectionSettings( connectionTimeoutInSeconds = connectionTimeoutSeconds, totalTimeoutInSeconds = totalTimeoutSeconds, @@ -228,10 +212,10 @@ class EsclScannerDiscoveryBackend( is ScannerCapabilitiesResult.Failure, is ScannerCapabilitiesResult.InvalidScannerHandle -> { _logger.debug { "Scanner ${scanner.handle.stringRepresentation} is not reachable. Result ${result.value}" } - false + null } - else -> true + else -> result.value } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt index 11aeb966..28ef127b 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt @@ -1,9 +1,11 @@ package io.github.chrisimx.scanbridge.model +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.ktor.http.Url data class DiscoveredScanner( val name: String, val iconUrl: Url?, - val handle: ScannerHandle + val handle: ScannerHandle, + val scannerCaps: ScannerCapabilitiesResult? = null ) From 24ecb274b1aa23ac4c3eec2b19567feeca243e1e Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Mon, 18 May 2026 00:06:35 +0200 Subject: [PATCH 06/38] Partially use scan handles in scanning screen --- .../chrisimx/scanbridge/ScanBridgeApp.kt | 1 - .../scanbridge/ScanBridgeApplication.kt | 3 ++ .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 28 +++++++----- .../chrisimx/scanbridge/ScannerBrowser.kt | 17 ++++++-- .../chrisimx/scanbridge/ScanningScreen.kt | 8 ++-- .../data/ui/ScanningScreenViewModel.kt | 31 +++++++------ .../uicomponents/FoundScannerItem.kt | 29 ++++++------- .../KoinBasedScanningProtocolManager.kt | 43 +++++++++++++++++++ .../escl/EsclScannerDiscoveryBackend.kt | 3 +- .../scanbridge/escl/EsclScanningProtocol.kt | 13 ++++++ .../scanbridge/model/DiscoveredScanner.kt | 4 +- .../scanbridge/model/ScannerHandle.kt | 7 +++ .../scanbridge/ports/ScanningProtocol.kt | 20 +++++---- .../ports/ScanningProtocolManager.kt | 11 +++++ .../scannerdiscovery/DiscoveryUsecase.kt | 14 ++---- 15 files changed, 160 insertions(+), 72 deletions(-) create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocolManager.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt index c32b116a..94084f23 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt @@ -55,7 +55,6 @@ fun ScanBridgeApp() { ScanBridgeTheme { val navController = rememberNavController() var startDestination: Any? by remember { mutableStateOf(null) } - val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val currentBackStackEntry by navController.currentBackStackEntryAsState() diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 59b6e64b..0425345d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -8,6 +8,7 @@ import android.content.Context import androidx.datastore.core.DataStore import coil3.ImageLoader import coil3.network.ktor3.KtorNetworkFetcherFactory +import io.github.chrisimx.scanbridge.adapters.KoinBasedScanningProtocolManager import io.github.chrisimx.scanbridge.adapters.RoomBackedCustomScannerRepository import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel @@ -29,6 +30,7 @@ import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages import io.github.chrisimx.scanbridge.repositories.DatastoreLastRouteRepository @@ -112,6 +114,7 @@ val appModule = module { single { (scope: CoroutineScope) -> DatastoreShownMessagesRepository(get(named()), scope) } bind ShownMessagesRepository::class + single() bind ScanningProtocolManager::class factory() viewModel() single() bind CustomScannerRepository::class diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index d7a0ec15..a3518092 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -19,9 +19,7 @@ package io.github.chrisimx.scanbridge -import android.app.Application import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -31,17 +29,18 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute import io.github.chrisimx.scanbridge.datastore.appSettingsStore +import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager import io.github.chrisimx.scanbridge.proto.scanningResponseTimeoutOrNull import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.TemporaryFileHandler import io.github.chrisimx.scanbridge.util.doTempFilesExist -import io.ktor.http.Url import kotlin.uuid.Uuid import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import org.koin.compose.koinInject import timber.log.Timber @Serializable @@ -53,7 +52,7 @@ object StartUpScreenRoute : BaseRoute @Serializable @SerialName("ScannerRoute") -data class ScannerRoute(val scannerName: String, val scannerURL: String, val sessionID: String) : BaseRoute +data class ScannerRoute(val scannerName: String, val scannerHandleString: String, val protocolId: String, val sessionID: String) : BaseRoute @Serializable @SerialName("CropImageRoute") @@ -74,11 +73,12 @@ fun NavBackStackEntry.toTypedRoute(): BaseRoute? { CropImageRoute(scanId, returnRouteString) } - "ScannerRoute/{scannerName}/{scannerURL}/{sessionID}" -> { + "ScannerRoute/{scannerName}/{scannerHandleString}/{protocolId}/{sessionID}" -> { val scannerName = arguments?.getString("scannerName") ?: return null - val scannerURL = arguments?.getString("scannerURL") ?: return null + val scannerHandling = arguments?.getString("scannerHandleString") ?: return null + val protocolId = arguments?.getString("protocolId") ?: return null val sessionID = arguments?.getString("sessionID") ?: return null - ScannerRoute(scannerName, scannerURL, sessionID) + ScannerRoute(scannerName, scannerHandling, protocolId, sessionID) } "ErrorRoute/{error}" -> { @@ -94,6 +94,7 @@ fun NavBackStackEntry.toTypedRoute(): BaseRoute? { fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { val context = LocalContext.current val appSettings = context.appSettingsStore.data + val protocolManager = koinInject() NavHost( modifier = Modifier.testTag("root_node"), @@ -130,19 +131,26 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { val debug = appSettingsCurrent.writeDebug val certValidationDisabled = appSettingsCurrent.disableCertChecks val timeout = appSettingsCurrent.scanningResponseTimeoutOrNull?.value?.toUInt() ?: 25u + val scannerHandle = protocolManager.getScannerHandle( + scannerRoute.protocolId, + scannerRoute.scannerHandleString + ) Timber.tag("ScanBridgeNavHost") .d( - "Navigating to scanner ${scannerRoute.scannerName} at ${scannerRoute.scannerURL}. Timeout is $timeout seconds, Debug is $debug. Disabling of cert checks is $certValidationDisabled. Session id is ${scannerRoute.sessionID}" + "Navigating to scanner ${scannerRoute.scannerName} at ${scannerRoute.scannerHandleString}. Timeout is $timeout seconds, Debug is $debug. Disabling of cert checks is $certValidationDisabled. Session id is ${scannerRoute.sessionID}" ) + + check(scannerHandle != null) { + "Scanner handle not found for protocol ${scannerRoute.protocolId} and handle ${scannerRoute.scannerHandleString}" + } ScanningScreen( scannerRoute.scannerName, - Url(scannerRoute.scannerURL), + scannerHandle, navController, timeout, debug, certValidationDisabled, Uuid.parse(scannerRoute.sessionID), - context.applicationContext as Application ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index a09ae7df..8c185721 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -78,7 +78,14 @@ fun ScannerList( ) { discoveredScanners.forEach { discoveredScanner -> item { - FoundScannerItem(discoveredScanner.name, discoveredScanner.iconUrl, discoveredScanner.handle, navController) + val handle = discoveredScanner.handle + FoundScannerItem( + handle.stringRepresentation, + handle.protocol.protocolIdentifier, + discoveredScanner.name, + discoveredScanner.iconUrl?.toString(), + navController + ) } } @@ -98,11 +105,12 @@ fun ScannerList( customScanners.forEach { customScanner -> item { - // TODO: Store handles directly instead of converting to UrlHandle here + // TODO: Store protocol in database FoundScannerItem( + customScanner.url.toString(), + "eSCL", customScanner.name, - null, // No downloaded icon for custom scanners - UrlScannerHandle(getKoin().get(), customScanner.url), + null, navController, { setScannerToDelete(customScanner.uuid) @@ -197,6 +205,7 @@ fun ScannerBrowser( ScannerRoute( name, url, + "eSCL", // TODO: Store protocol in database and make this choosable sessionID.toString() ) ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index d332932a..2a75840c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -20,7 +20,6 @@ package io.github.chrisimx.scanbridge import android.annotation.SuppressLint -import android.app.Application import android.content.Context import android.net.Uri import androidx.activity.compose.BackHandler @@ -93,6 +92,7 @@ import androidx.navigation.NavHostController import coil3.compose.AsyncImage import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.services.ScanJobEvent import io.github.chrisimx.scanbridge.uicomponents.ExportSettingsPopup import io.github.chrisimx.scanbridge.uicomponents.FullScreenError @@ -104,7 +104,6 @@ import io.github.chrisimx.scanbridge.util.clearAndNavigateTo import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage import io.github.chrisimx.scanbridge.util.toReadableString -import io.ktor.http.Url import java.io.File import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope @@ -278,16 +277,15 @@ fun saveFile(context: Context, sourceFile: File, destUri: Uri) { @Composable fun ScanningScreen( scannerName: String, - scannerAddress: Url, + scannerHandle: ScannerHandle, navController: NavHostController, timeout: UInt, withDebug: Boolean, certificateValidationDisabled: Boolean, sessionID: Uuid, - application: Application, scanningViewModel: ScanningScreenViewModel = koinViewModel { parametersOf( - scannerAddress, + scannerHandle, timeout, withDebug, certificateValidationDisabled, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index f828ba50..b854db34 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -59,8 +59,13 @@ import io.github.chrisimx.scanbridge.model.HttpClientConfig import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanRelativeRotation import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.model.scannerCapabilities import io.github.chrisimx.scanbridge.model.toggleRotation import io.github.chrisimx.scanbridge.ports.HttpClientFactory +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull import io.github.chrisimx.scanbridge.services.ScanJobRepository import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore @@ -104,7 +109,7 @@ enum class ScanningScreenEvent { class ScanningScreenViewModel( @InjectedParam - val address: Url, + val scannerHandle: ScannerHandle, @InjectedParam val timeout: UInt, @InjectedParam @@ -116,7 +121,6 @@ class ScanningScreenViewModel( val db: ScanBridgeDb, application: Application, val scanJobRepo: ScanJobRepository, - val httpClientFactory: HttpClientFactory ) : AndroidViewModel(application) { private val _scanningScreenData = ScanningScreenData( @@ -490,7 +494,7 @@ class ScanningScreenViewModel( Uuid.generateV4(), sessionID, currentSettings, - address, + (scannerHandle as UrlScannerHandle).url, createHttpClientConfig() ) scanJobRepo.enqueue(scanJob) @@ -757,22 +761,21 @@ class ScanningScreenViewModel( ) fun retrieveScannerCapabilities() = viewModelScope.launch { - val httpClient = httpClientFactory.create( - createHttpClientConfig() - ) - val esclClient = ESCLRequestClient( - address, - httpClient + val connectionSettings = ScannerConnectionSettings( + timeout.toULong(), + timeout.toULong(), + certificateValidationDisabled, + withDebugInterceptor ) - val scannerCapabilitiesResult = esclClient.getScannerCapabilities() + val scannerCapabilities = scannerHandle.scannerCapabilities(connectionSettings) - if (scannerCapabilitiesResult !is ESCLRequestClient.ScannerCapabilitiesResult.Success) { - Timber.e("Error while retrieving ScannerCapabilities: $scannerCapabilitiesResult") - setError("$scannerCapabilitiesResult") + if (scannerCapabilities !is ScannerCapabilitiesResult.Success) { + Timber.e("Error while retrieving ScannerCapabilities: $scannerCapabilities") + setError("$scannerCapabilities") return@launch } - setScannerCapabilities(scannerCapabilitiesResult.scannerCapabilities) + setScannerCapabilities(scannerCapabilities.scannerCapabilities) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index ee3332a4..7e4832e6 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt @@ -41,12 +41,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.addPathNodes -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -55,9 +51,6 @@ import coil3.ImageLoader import coil3.compose.AsyncImage import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.ScannerRoute -import io.github.chrisimx.scanbridge.model.ScannerHandle -import io.github.chrisimx.scanbridge.model.UrlScannerHandle -import io.ktor.http.Url import java.util.* import kotlin.uuid.Uuid import org.koin.compose.koinInject @@ -90,9 +83,10 @@ fun tintedPainterResource( @Composable fun FoundScannerItem( + scannerHandleString: String, + protocolIdentifier: String, name: String, - iconUrl: Url?, - handle: ScannerHandle, + iconUrl: String?, navController: NavController, deleteScanner: (() -> Unit)? = null, editScanner: (() -> Unit)? = null @@ -104,9 +98,12 @@ fun FoundScannerItem( .padding(10.dp), onClick = { val sessionID = Uuid.random() - // TODO: Use scanner handle instead of URL - val url = (handle as UrlScannerHandle).url - navController.navigate(route = ScannerRoute(name, url.toString(), sessionID.toString())) + navController.navigate(route = ScannerRoute( + name, + scannerHandleString, + protocolIdentifier, + sessionID.toString()) + ) } ) { Row( @@ -116,7 +113,7 @@ fun FoundScannerItem( verticalAlignment = Alignment.CenterVertically ) { val tintedPlaceholder = tintedPainterResource( - io.github.chrisimx.scanbridge.R.drawable.round_print_36, + R.drawable.round_print_36, MaterialTheme.colorScheme.surfaceTint ) @@ -165,7 +162,7 @@ fun FoundScannerItem( ) } Text( - "${handle.stringRepresentation} (${handle.protocol.protocolIdentifier})", + "$scannerHandleString (${protocolIdentifier})", style = MaterialTheme.typography.labelLarge ) } @@ -174,7 +171,7 @@ fun FoundScannerItem( modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp), onClick = { editScanner.invoke() - Timber.i("Edit button clicked for custom scanner: $name at $handle") + Timber.i("Edit button clicked for custom scanner: $name at $scannerHandleString") } ) { Icon( @@ -187,7 +184,7 @@ fun FoundScannerItem( modifier = Modifier.padding(end = 8.dp, top = 8.dp, bottom = 8.dp), onClick = { deleteScanner.invoke() - Timber.i("Delete button clicked for custom scanner: $name at $handle") + Timber.i("Delete button clicked for custom scanner: $name at $scannerHandleString") } ) { Icon( diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt new file mode 100644 index 00000000..89787007 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt @@ -0,0 +1,43 @@ +package io.github.chrisimx.scanbridge.adapters + +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager +import kotlinx.coroutines.CoroutineScope +import org.koin.core.component.KoinComponent + +class KoinBasedScanningProtocolManager( + private val scanningProtocols: List +): ScanningProtocolManager, KoinComponent { + private val protocolById = scanningProtocols.associateBy { + it.protocolIdentifier + } + + override fun getAllProtocols(): List { + return scanningProtocols + } + + override fun getDiscoveryBackends( + coroutineScope: CoroutineScope, + connectionSettings: ScannerConnectionSettings + ): List { + return scanningProtocols.mapNotNull { + it.createDiscoveryBackend(coroutineScope, connectionSettings) + } + } + + override fun getProtocolFromIdentifier(identifier: String): ScanningProtocol? { + return protocolById[identifier] + } + + override fun getScannerHandle( + protocolIdentifier: String, + scannerHandleStringRepresentation: String + ): ScannerHandle? { + return getProtocolFromIdentifier(protocolIdentifier)?.let { protocol -> + return protocol.createScannerHandle(scannerHandleStringRepresentation) + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt index 3c2b5b4c..e649a628 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -121,8 +121,9 @@ class EsclScannerDiscoveryBackend( DiscoveredScanner( scannerName, - iconUrlWithResolvedIp, scannerHandle, + null, + iconUrlWithResolvedIp, ) } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index ea54f00f..059a9978 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -14,6 +14,7 @@ import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import io.github.chrisimx.scanbridge.ports.ScanningProtocol import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.ktor.http.Url import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -37,6 +38,18 @@ class EsclScanningProtocol( } } + override fun createScannerHandle(scannerIdentifier: String): ScannerHandle? { + + val url = runCatching { Url(scannerIdentifier) }.getOrNull() + + return url?.let { + UrlScannerHandle( + this, + it + ) + } + } + override fun createDiscoveryBackend(coroutineScope: CoroutineScope, settings: ScannerConnectionSettings): ScannerDiscoveryBackend = EsclScannerDiscoveryBackend( mdnsDiscoverySecureEscl, diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt index 28ef127b..b87ce5b7 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt @@ -5,7 +5,7 @@ import io.ktor.http.Url data class DiscoveredScanner( val name: String, - val iconUrl: Url?, val handle: ScannerHandle, - val scannerCaps: ScannerCapabilitiesResult? = null + val scannerCaps: ScannerCapabilitiesResult? = null, + val iconUrl: Url? = null, ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt index 22757d68..d47420dd 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt @@ -1,5 +1,7 @@ package io.github.chrisimx.scanbridge.model +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.github.chrisimx.scanbridge.ports.ScanningProtocol import io.ktor.http.Url @@ -8,6 +10,11 @@ sealed interface ScannerHandle { val stringRepresentation: String } +suspend fun ScannerHandle.scannerCapabilities(settings: ScannerConnectionSettings): ScannerCapabilitiesResult { + val protocol = this.protocol + return protocol.capabilitiesFor(this, settings) +} + data class UrlScannerHandle( override val protocol: ScanningProtocol, val url: Url diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt index ba186a5a..54b7a6e2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -32,7 +32,6 @@ sealed class ScanJobProcessingEvent { interface ScanningProtocol { /** * A string that uniquely identifies this protocol (e.g. "eSCL", "WSD", ...) - * Maybe not needed, let's see... */ val protocolIdentifier: String @@ -43,6 +42,17 @@ interface ScanningProtocol { */ val usesUrls: Boolean + /** + * Creates a [ScannerHandle] for the given scanner identifier. This handle can be used for the rest of the API. + * + * The meaning of the string [scannerIdentifier] is protocol dependent. It could be a URL, + * a PCI device number or something else. This is left free to allow support for protocols that + * do not use URLs. + * Within a [ScanningProtocol] this identifier should have + * consistent meaning. + */ + fun createScannerHandle(scannerIdentifier: String): ScannerHandle? + /** * Creates a [ScannerDiscoveryBackend] that can be used to find scanners providing * this scanning protocol. @@ -53,19 +63,13 @@ interface ScanningProtocol { /** * Retrieves the capabilities of a scanner identified by the given string. - * - * The meaning of the string [scannerIdentifier] is protocol dependent. It could be a URL, - * a PCI device number or something else. This is left free to allow support for protocols that - * do not use URLs. - * Within a [ScanningProtocol] this identifier should have - * consistent meaning. */ suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult /** * Executes the scan job on the given scanner. * - * Each process update is provided as a [ScanJobProcessingEvnt]. This also provides + * Each process update is provided as a [ScanJobProcessingEvent]. This also provides * the scanned pages if the scanning job succeeds. */ fun executeScanJob(handle: ScannerHandle, settings: ScannerConnectionSettings, jobScanSettings: ScanSettings): Flow diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocolManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocolManager.kt new file mode 100644 index 00000000..089486ac --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocolManager.kt @@ -0,0 +1,11 @@ +package io.github.chrisimx.scanbridge.ports + +import io.github.chrisimx.scanbridge.model.ScannerHandle +import kotlinx.coroutines.CoroutineScope + +interface ScanningProtocolManager { + fun getAllProtocols(): List + fun getDiscoveryBackends(coroutineScope: CoroutineScope, connectionSettings: ScannerConnectionSettings): List + fun getProtocolFromIdentifier(identifier: String): ScanningProtocol? + fun getScannerHandle(protocolIdentifier: String, scannerHandleStringRepresentation: String): ScannerHandle? +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt index 28de65f7..685e84bb 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt @@ -2,28 +2,20 @@ package io.github.chrisimx.scanbridge.scannerdiscovery import io.github.chrisimx.scanbridge.model.DiscoveredScanner import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings -import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn -import org.koin.core.component.KoinComponent class DiscoveryUsecase( - private val scanningProtocols: List, + private val scanningProtocolManager: ScanningProtocolManager ) { fun discoveredScanners( coroutineScope: CoroutineScope, connectionSettings: ScannerConnectionSettings = ScannerConnectionSettings(), ): Flow> { - val discoveryBackends = - scanningProtocols.mapNotNull { protocol -> - protocol.createDiscoveryBackend(coroutineScope, connectionSettings) - } - + val discoveryBackends = scanningProtocolManager.getDiscoveryBackends(coroutineScope, connectionSettings) if (discoveryBackends.isEmpty()) { return flowOf(emptyList()) } From 767a97ce45a835862cf31de0770f92a914a16209 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 22 May 2026 23:43:22 +0200 Subject: [PATCH 07/38] Remove protocol dependency from scanning screen (now only impl in ScanJobForegroundService missing) --- .../ScanJobForegroundService.kt | 8 +++-- .../data/ui/ScanningScreenViewModel.kt | 23 ++++---------- .../scanbridge/escl/EsclScanningProtocol.kt | 30 ++++++++----------- .../scanbridge/model/HttpClientConfig.kt | 10 +++++++ .../chrisimx/scanbridge/model/ScanJob.kt | 6 ++-- .../scanbridge/ports/ScanningProtocol.kt | 12 ++++++-- 6 files changed, 46 insertions(+), 43 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt index 4f95b5c9..183bbc2e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt @@ -11,7 +11,6 @@ import android.graphics.BitmapFactory import android.os.IBinder import androidx.compose.ui.graphics.asImageBitmap import androidx.core.app.NotificationCompat -import androidx.lifecycle.application import androidx.room.immediateTransaction import androidx.room.useWriterConnection import io.github.chrisimx.esclkt.ESCLHttpCallResult @@ -25,6 +24,8 @@ import io.github.chrisimx.scanbridge.db.daos.ScannedPageDao import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanRelativeRotation +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.model.toHttpClientConfig import io.github.chrisimx.scanbridge.ports.HttpClientFactory import io.github.chrisimx.scanbridge.services.ScanJobRepository import io.github.chrisimx.scanbridge.util.extractPdfImages @@ -169,10 +170,11 @@ class ScanJobForegroundService : Service() { @OptIn(ExperimentalUuidApi::class) private suspend fun doScan(scanJob: ScanJob) { val currentScanSettings = scanJob.scanSettings + val scannerUrl = (scanJob.scannerHandle as UrlScannerHandle) val esclRequestClient = ESCLRequestClient( - scanJob.scannerBaseUrl, - httpClientFactory.create(scanJob.httpClientConfig) + scannerUrl.url, + httpClientFactory.create(scanJob.connectionSettings.toHttpClientConfig()) ) scanJobs.notifyStarted(scanJob) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index b854db34..88add2b1 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -37,7 +37,6 @@ import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image -import io.github.chrisimx.esclkt.ESCLRequestClient import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.ScanRegion import io.github.chrisimx.esclkt.ScanSettings @@ -55,15 +54,12 @@ import io.github.chrisimx.scanbridge.db.ScanBridgeDb 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.model.HttpClientConfig import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanRelativeRotation import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData import io.github.chrisimx.scanbridge.model.ScannerHandle -import io.github.chrisimx.scanbridge.model.UrlScannerHandle import io.github.chrisimx.scanbridge.model.scannerCapabilities import io.github.chrisimx.scanbridge.model.toggleRotation -import io.github.chrisimx.scanbridge.ports.HttpClientFactory import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull @@ -76,7 +72,6 @@ import io.github.chrisimx.scanbridge.util.rotateBy90 import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage import io.github.chrisimx.scanbridge.util.zipFiles -import io.ktor.http.Url import java.io.File import java.nio.file.Files import java.time.LocalDateTime @@ -494,8 +489,8 @@ class ScanningScreenViewModel( Uuid.generateV4(), sessionID, currentSettings, - (scannerHandle as UrlScannerHandle).url, - createHttpClientConfig() + scannerHandle, + createScannerConnectionSettings() ) scanJobRepo.enqueue(scanJob) ScanJobForegroundService.startService(application) @@ -752,21 +747,15 @@ class ScanningScreenViewModel( } } - fun createHttpClientConfig() = HttpClientConfig( - certificateValidationDisabled, - withDebugInterceptor, + fun createScannerConnectionSettings() = ScannerConnectionSettings( timeout.toULong(), timeout.toULong(), - timeout.toULong() + certificateValidationDisabled, + withDebugInterceptor ) fun retrieveScannerCapabilities() = viewModelScope.launch { - val connectionSettings = ScannerConnectionSettings( - timeout.toULong(), - timeout.toULong(), - certificateValidationDisabled, - withDebugInterceptor - ) + val connectionSettings = createScannerConnectionSettings() val scannerCapabilities = scannerHandle.scannerCapabilities(connectionSettings) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index 059a9978..97dd8739 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -6,6 +6,7 @@ import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.scanbridge.model.HttpClientConfig import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.model.toHttpClientConfig import io.github.chrisimx.scanbridge.ports.HttpClientFactory import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory @@ -17,6 +18,7 @@ import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.ktor.http.Url import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow class EsclScanningProtocol( @@ -63,7 +65,7 @@ class EsclScanningProtocol( val scannerUrlHandle = scanner as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) - val httpConfig = deriveHttpConfigFromConnectionSettings(settings) + val httpConfig = settings.toHttpClientConfig() val httpClient = httpClientFactory.create(httpConfig) val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) @@ -86,29 +88,23 @@ class EsclScanningProtocol( } } - private fun deriveHttpConfigFromConnectionSettings(settings: ScannerConnectionSettings): HttpClientConfig = HttpClientConfig( - settings.allowSelfSignedCertificates, - settings.debugLogging, - settings.connectionTimeoutInSeconds, - settings.connectionTimeoutInSeconds, - settings.totalTimeoutInSeconds, - ) - override fun executeScanJob( handle: ScannerHandle, settings: ScannerConnectionSettings, - jobScanSettings: ScanSettings + jobScanSettings: ScanSettings, + cancelled: StateFlow ): Flow = flow { - /*val scannerUrlHandle = - handle as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) + val scannerUrlHandle = + handle as? UrlScannerHandle - requireNotNull(scannerUrlHandle) { "Scanner handle is not a URL handle" } + if (scannerUrlHandle == null) { + emit(ScanJobProcessingEvent.InvalidScannerHandle(handle)) + return@flow + } - val httpConfig = deriveHttpConfigFromConnectionSettings(settings) + val httpConfig = settings.toHttpClientConfig() val httpClient = httpClientFactory.create(httpConfig) - val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient)*/ - - emit(ScanJobProcessingEvent.Failure(Exception("Not implemented"))) + val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt index 66c74699..a7c4b41d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt @@ -1,5 +1,7 @@ package io.github.chrisimx.scanbridge.model +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings + data class HttpClientConfig( val disableCertValidation: Boolean, val debugLogging: Boolean, @@ -7,3 +9,11 @@ data class HttpClientConfig( val socketTimeoutInSeconds: ULong, val requestTimeoutInSeconds: ULong ) + +fun ScannerConnectionSettings.toHttpClientConfig(): HttpClientConfig = HttpClientConfig( + this.allowSelfSignedCertificates, + this.debugLogging, + this.connectionTimeoutInSeconds, + this.connectionTimeoutInSeconds, + this.totalTimeoutInSeconds, +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt index b4aa2563..e022c045 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt @@ -1,13 +1,13 @@ package io.github.chrisimx.scanbridge.model import io.github.chrisimx.esclkt.ScanSettings -import io.ktor.http.Url +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import kotlin.uuid.Uuid data class ScanJob( val jobID: Uuid, val ownerSessionId: Uuid, val scanSettings: ScanSettings, - val scannerBaseUrl: Url, - val httpClientConfig: HttpClientConfig + val scannerHandle: ScannerHandle, + val connectionSettings: ScannerConnectionSettings ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt index 54b7a6e2..cd8dca0e 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -6,6 +6,7 @@ import io.github.chrisimx.esclkt.ScannerCapabilities import io.github.chrisimx.scanbridge.model.ScannerHandle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow data class ScannerConnectionSettings( val connectionTimeoutInSeconds: ULong = 10uL, @@ -24,8 +25,9 @@ sealed class ScannerCapabilitiesResult { } sealed class ScanJobProcessingEvent { - data class Update(val progress: Double) : ScanJobProcessingEvent() - data class Success(val scannedPages: ESCLRequestClient.ScannedPage) : ScanJobProcessingEvent() + data class NewPage(val scannedPages: ESCLRequestClient.ScannedPage) : ScanJobProcessingEvent() + data class InvalidScannerHandle(val scannerHandle: ScannerHandle) : ScanJobProcessingEvent() + data object Cancelled : ScanJobProcessingEvent() data class Failure(val reason: Any) : ScanJobProcessingEvent() } @@ -72,5 +74,9 @@ interface ScanningProtocol { * Each process update is provided as a [ScanJobProcessingEvent]. This also provides * the scanned pages if the scanning job succeeds. */ - fun executeScanJob(handle: ScannerHandle, settings: ScannerConnectionSettings, jobScanSettings: ScanSettings): Flow + fun executeScanJob(handle: ScannerHandle, + settings: ScannerConnectionSettings, + jobScanSettings: ScanSettings, + cancelled: StateFlow + ): Flow } From cbebc009d6ced589c1e69594fd7ebc8c8a9c5d83 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 27 May 2026 18:28:05 +0200 Subject: [PATCH 08/38] Introduce database schema version 5 and remove protocol dependency from ScanJobForegroundService --- .../chrisimx/scanbridge/ScanningScreen.kt | 24 +- .../ScanJobForegroundService.kt | 279 ++++-------------- .../scanbridge/services/ScanJobRepository.kt | 9 +- .../chrisimx/scanbridge/util/UIUtils.kt | 43 ++- .../5.json | 262 ++++++++++++++++ .../chrisimx/scanbridge/db/ScanBridgeDb.kt | 6 +- .../scanbridge/db/entities/ScannedPage.kt | 5 +- .../scanbridge/escl/EsclScanningProtocol.kt | 204 ++++++++++++- .../model/ScanProtocolScannedPage.kt | 25 ++ .../scanbridge/model/ScanningError.kt | 28 ++ .../scanbridge/ports/ScanningProtocol.kt | 7 +- 11 files changed, 650 insertions(+), 242 deletions(-) create mode 100644 core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 2a75840c..7e535242 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -54,11 +54,13 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold @@ -100,6 +102,8 @@ import io.github.chrisimx.scanbridge.uicomponents.LoadingScreen import io.github.chrisimx.scanbridge.uicomponents.dialog.ConfirmCloseDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.DeletionDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog +import io.github.chrisimx.scanbridge.util.CustomSnackbarVisuals +import io.github.chrisimx.scanbridge.util.SnackbarType import io.github.chrisimx.scanbridge.util.clearAndNavigateTo import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage @@ -327,7 +331,8 @@ fun ScanningScreen( scanningViewModel.scanJobRepo.events.collect { event -> when (event) { is ScanJobEvent.Completed -> scope.launch { pagerState.animateScrollToPage(scannedPages.size - 1) } - is ScanJobEvent.Failed -> snackbarErrorRetrievingPage(event.reason, scope, context, snackbarHostState) + // TODO: Localize this + is ScanJobEvent.Failed -> snackbarErrorRetrievingPage(event.error.unlocalizedMessage, scope, context, snackbarHostState) is ScanJobEvent.Started -> scope.launch { pagerState.animateScrollToPage(scannedPages.size) } } } @@ -386,16 +391,23 @@ fun ScanningScreen( modifier = Modifier.fillMaxSize(), snackbarHost = { SnackbarHost(snackbarHostState) { data -> + val visuals = data.visuals as? CustomSnackbarVisuals + val type = visuals?.type ?: SnackbarType.DEFAULT + Snackbar( modifier = Modifier.padding(20.dp), - containerColor = if (data.visuals.message.contains("Error")) { - MaterialTheme.colorScheme.error - } else { - SnackbarDefaults.color - }, + containerColor = type.containerColor, + contentColor = type.contentColor, + shape = RoundedCornerShape(16.dp), action = { IconButton( onClick = { data.dismiss() }, + colors = IconButtonColors( + type.containerColor, + type.contentColor, + type.containerColor, + type.contentColor + ), modifier = Modifier.testTag("snackbar_dismiss") ) { Icon(Icons.Default.Close, contentDescription = "Dismiss") diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt index 183bbc2e..b742b253 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt @@ -7,15 +7,10 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent -import android.graphics.BitmapFactory import android.os.IBinder -import androidx.compose.ui.graphics.asImageBitmap import androidx.core.app.NotificationCompat import androidx.room.immediateTransaction import androidx.room.useWriterConnection -import io.github.chrisimx.esclkt.ESCLHttpCallResult -import io.github.chrisimx.esclkt.ESCLRequestClient -import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.scanbridge.MainActivity import io.github.chrisimx.scanbridge.R @@ -24,16 +19,12 @@ import io.github.chrisimx.scanbridge.db.daos.ScannedPageDao import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanRelativeRotation -import io.github.chrisimx.scanbridge.model.UrlScannerHandle -import io.github.chrisimx.scanbridge.model.toHttpClientConfig +import io.github.chrisimx.scanbridge.model.ScanningError import io.github.chrisimx.scanbridge.ports.HttpClientFactory +import io.github.chrisimx.scanbridge.ports.ScanJobProcessingEvent import io.github.chrisimx.scanbridge.services.ScanJobRepository import io.github.chrisimx.scanbridge.util.extractPdfImages -import io.github.chrisimx.scanbridge.util.toJobStateString import java.io.File -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.absolutePathString import kotlin.jvm.java import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -41,9 +32,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import timber.log.Timber @@ -169,211 +158,75 @@ class ScanJobForegroundService : Service() { @OptIn(ExperimentalUuidApi::class) private suspend fun doScan(scanJob: ScanJob) { - val currentScanSettings = scanJob.scanSettings - val scannerUrl = (scanJob.scannerHandle as UrlScannerHandle) - - val esclRequestClient = ESCLRequestClient( - scannerUrl.url, - httpClientFactory.create(scanJob.connectionSettings.toHttpClientConfig()) - ) - scanJobs.notifyStarted(scanJob) - if (abortIfCancelling()) return - - Timber.d("Creating scan job. eSCLKt scan settings: $currentScanSettings") - val job = - esclRequestClient.createJob(currentScanSettings) - Timber.d("Creation request done. Result: $job") - if (job !is ESCLRequestClient.ScannerCreateJobResult.Success) { - Timber.e("Job creation failed. Result: $job") - - scanJobs.notifyFailed(scanJob, job.toString()) - return - } - val jobResult = job.scanJob - - if (abortIfCancelling(jobResult)) return - - var polling = false + val scanningProtocol = scanJob.scannerHandle.protocol + val scanningFlow = scanningProtocol.executeScanJob( + scanJob.scannerHandle, + scanJob.connectionSettings, + scanJob.scanSettings, + scanJobs.shouldCancel + ) - while (true) { - if (polling) { - for (retries in 0..60) { - if (abortIfCancelling(jobResult)) return - val status = jobResult.getJobStatus() - val isRunning = status?.jobState == JobState.Processing || status?.jobState == JobState.Pending - val imagesToTransfer = status?.imagesToTransfer - Timber.d( - "Polling job status. Retry: $retries Result: $status imagesToTransfer: $imagesToTransfer isRunning: $isRunning" - ) - if (!isRunning) { - Timber.d("Job is reported to be not running anymore. jobRunning = false") + var failed = false - val deleteResult = jobResult.cancel() - Timber.d("Cancelling job after (a likely) failure: $deleteResult") + var pageCounter = 1 - if (status?.jobState != JobState.Completed) { - val jobStateString = status?.jobState.toJobStateString(application) - Timber.w("Job info doesn't indicate completion: $jobStateString") - scanJobs.notifyFailed(scanJob, status.toString()) - } - return - } - if (imagesToTransfer != null && imagesToTransfer > 0u) { - Timber.d("There seem to be images to transfer. Breaking out of polling loop") - break - } - delay(1000) + scanningFlow.collect { processingEvent -> + when (processingEvent) { + ScanJobProcessingEvent.Cancelled -> scanJobs.setCancel(false) + is ScanJobProcessingEvent.Failure -> { + failed = true + scanJobs.notifyFailed(scanJob, processingEvent.error) + return@collect } - } - - if (abortIfCancelling(jobResult)) return - - Timber.d("Retrieving next page") - val nextPage = jobResult.retrieveNextPage() - Timber.d("Next page result: $nextPage") - val status = jobResult.getJobStatus() - Timber.d("Retrieved job info: $status") - val jobStateString = status?.jobState.toJobStateString(application) - Timber.d("Job info as human readable: $jobStateString") - when (nextPage) { - is ESCLRequestClient.ScannerNextPageResult.NoFurtherPages -> { - Timber.d("Next page result is seen as no further pages. jobRunning = false") - - if (status?.jobState != JobState.Completed) { - Timber.w("Job info doesn't indicate completion: $jobStateString") - scanJobs.notifyFailed( - scanJob, - application.getString( - R.string.no_further_pages, - jobStateString + is ScanJobProcessingEvent.NewPage -> { + val pageData = processingEvent.scannedPage + + val scanPageFileName = "scan-" + Uuid.random().toString() + val scanPageFile = File(application.filesDir, scanPageFileName) + + scanPageFile.writeBytes(pageData.data) + + when (pageData.contentType) { + // TODO: Extract this conversion to a service + "image/jpeg" -> { + addScan( + scanJob.ownerSessionId, + scanPageFile.absolutePath, + scanJob.scanSettings, + ScanRelativeRotation.Original, + "scan-${pageCounter.toString().padStart(4, '0')}.jpg" ) - ) - } else { - scanJobs.notifyCompleted(scanJob) - } - val deletionResult = jobResult.cancel() - Timber.d("Cancelling job after no further pages is reported: $deletionResult") - return - } - - is ESCLRequestClient.ScannerNextPageResult.RequestFailure -> { - if (nextPage.exception !is ESCLHttpCallResult.Error.HttpError) { - reportErrorWhileScanning(scanJob, nextPage, jobResult) - return - } - val error = nextPage.exception as ESCLHttpCallResult.Error.HttpError - - if (status?.jobState == JobState.Completed) { - Timber.d("Job info indicates completion but response was not 404: $jobStateString") - scanJobs.notifyFailed( - scanJob, - application.getString( - R.string.no_further_pages, - jobStateString - ) - ) - val deletionResult = jobResult.cancel() - Timber.d("Cancelling job after non-standard completion: $deletionResult") - return - } else { - Timber.e("Not successful code while retrieving next page: $nextPage") - if (error.code == 503) { - // Retry with polling - Timber.d("503 error received. Retrying with polling") - polling = true - continue - } else { - scanJobs.notifyFailed(scanJob, nextPage.toString()) - val deletionResult = jobResult.cancel() - Timber.d("Cancelling job after not successful response while trying to retrieve page: $deletionResult") - return } - } - } - - is ESCLRequestClient.ScannerNextPageResult.Success -> { - } + "application/pdf" -> { + val extractedImages = extractPdfImages( + scanPageFile.absolutePath, + File(scanPageFile.parent!!) + ) - else -> { - reportErrorWhileScanning(scanJob, nextPage, jobResult) - return - } - } - Timber.d("Received page. Copying to file") - var filePath: Path - while (true) { - val scanPageFile = "scan-" + Uuid.random().toString() - val file = File(application.filesDir, scanPageFile) - file.exists().let { - if (!it) { - filePath = file.toPath() - break + extractedImages.forEach { + addScan(scanJob.ownerSessionId, it, scanJob.scanSettings, ScanRelativeRotation.Original) + } + } + else -> { + failed = true + scanJobs.notifyFailed(scanJob, ScanningError.UnsupportedContentType(pageData.contentType)) + return@collect + } } - } - } - Timber.d("Scan page file created: $filePath") - - try { - withContext(Dispatchers.IO) { - Files.copy(nextPage.page.data.inputStream(), filePath) + pageCounter++ } - } catch (e: Exception) { - Timber.e(e, "Error while copying received image to file. Aborting!") - scanJobs.notifyFailed( - scanJob, - application.getString( - R.string.error_while_copying_received_image_to_file, - e.message - ) - ) - val deletionResult = jobResult.cancel() - Timber.d("Cancelling job after error while trying to copy received page to file: $deletionResult") - return } + } - val images = if (scanJob.scanSettings.documentFormatExt == "application/pdf") { - extractPdfImages( - filePath.absolutePathString(), - filePath.parent.toFile() - ) - } else { - val imageBitmap = withContext(Dispatchers.IO) { - BitmapFactory.decodeFile(filePath.toString())?.asImageBitmap() - } - - if (imageBitmap == null) { - Timber.e("Couldn't decode received image as Bitmap. Aborting!") - scanJobs.notifyFailed( - scanJob, - application.getString(R.string.couldn_t_decode_received_image, jobStateString) - ) - filePath.toFile().delete() - val deletionResult = jobResult.cancel() - Timber.d("Cancelling job after error while trying to decode received page as bitmap: $deletionResult") - return - } - - val renamedPath = File("$filePath.jpg").toPath() - withContext(Dispatchers.IO) { - Files.move( - filePath.toAbsolutePath(), - renamedPath - ) - } - - listOf(renamedPath.toString()) - } + if (failed) return - images.forEach { - addScan(scanJob.ownerSessionId, it, currentScanSettings, ScanRelativeRotation.Original) - } - } + scanJobs.notifyCompleted(scanJob) } - suspend fun addScan(sessionID: Uuid, path: String, settings: ScanSettings, rotation: ScanRelativeRotation) { + suspend fun addScan(sessionID: Uuid, path: String, settings: ScanSettings, rotation: ScanRelativeRotation, fileName: String? = null) { Timber.d("Adding scan: $path, $rotation") db.useWriterConnection { it.immediateTransaction { @@ -387,31 +240,11 @@ class ScanJobForegroundService : Service() { path, settings, rotation, - highestIdx + 1 + highestIdx + 1, + fileName ) ) } } } - - private suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (scanJobs.shouldCancel.value) { - Timber.d("Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob") - scanJob?.cancel() - - scanJobs.setCancel(false) - true - } else { - false - } - - private suspend fun reportErrorWhileScanning( - scanJob: ScanJob, - nextPage: ESCLRequestClient.ScannerNextPageResult, - jobResult: io.github.chrisimx.esclkt.ScanJob - ) { - Timber.e("Error while retrieving next page: $nextPage") - scanJobs.notifyFailed(scanJob, nextPage.toString()) - val deletionResult = jobResult.cancel() - Timber.d("Cancelling job after error while trying to retrieve page: $deletionResult") - } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt index 703127a2..2abc492e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt @@ -1,6 +1,7 @@ package io.github.chrisimx.scanbridge.services import io.github.chrisimx.scanbridge.model.ScanJob +import io.github.chrisimx.scanbridge.model.ScanningError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow @@ -17,7 +18,7 @@ import timber.log.Timber sealed class ScanJobEvent { data class Completed(val job: ScanJob) : ScanJobEvent() - data class Failed(val job: ScanJob, val reason: String) : ScanJobEvent() + data class Failed(val job: ScanJob, val error: ScanningError) : ScanJobEvent() data class Started(val job: ScanJob) : ScanJobEvent() } @@ -89,10 +90,10 @@ class ScanJobRepository { /** * Notify that a job failed */ - fun notifyFailed(job: ScanJob, reason: String) { - Timber.d("notifyFailed($job, $reason)") + fun notifyFailed(job: ScanJob, error: ScanningError) { + Timber.d("notifyFailed($job, $error)") coroutineScope.launch { - _events.emit(ScanJobEvent.Failed(job, reason)) + _events.emit(ScanJobEvent.Failed(job, error)) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt index 15b84d52..973d9fc2 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt @@ -23,8 +23,11 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Context.CLIPBOARD_SERVICE +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SnackbarVisuals +import androidx.compose.ui.graphics.Color import io.github.chrisimx.scanbridge.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -35,6 +38,24 @@ fun String.truncate(maxLength: Int): String = if (this.length <= maxLength) { this.take(maxLength.coerceAtLeast(1) - 1) + "…" } +enum class SnackbarType( + val containerColor: Color, + val contentColor: Color = Color.White, +) { + SUCCESS(containerColor = Color(0xFF4CAF50)), + ERROR(containerColor = Color(0xFFF44336)), + WARNING(containerColor = Color(0xFFFF9800)), + DEFAULT(containerColor = Color.DarkGray); +} + +data class CustomSnackbarVisuals( + override val message: String, + override val actionLabel: String? = null, + override val duration: SnackbarDuration = SnackbarDuration.Short, + override val withDismissAction: Boolean = false, + val type: SnackbarType = SnackbarType.DEFAULT, +) : SnackbarVisuals + fun snackbarErrorRetrievingPage( error: String, scope: CoroutineScope, @@ -52,6 +73,24 @@ fun snackbarErrorRetrievingPage( ) } +suspend fun SnackbarHostState.showCustomSnackbar( + message: String, + type: SnackbarType = SnackbarType.DEFAULT, + actionLabel: String? = null, + duration: SnackbarDuration = SnackbarDuration.Short, + withDismissAction: Boolean = false, +): SnackbarResult { + return showSnackbar( + CustomSnackbarVisuals( + message = message, + actionLabel = actionLabel, + duration = duration, + withDismissAction = withDismissAction, + type = type + ) + ) +} + fun snackBarError( error: String, scope: CoroutineScope, @@ -61,9 +100,11 @@ fun snackBarError( copyData: String? = null ) { scope.launch { - val result = snackbarHostState.showSnackbar( + val result = snackbarHostState.showCustomSnackbar( error, + SnackbarType.ERROR, if (action) context.getString(R.string.copy) else null, + SnackbarDuration.Indefinite, true ) when (result) { diff --git a/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json new file mode 100644 index 00000000..204ae79a --- /dev/null +++ b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json @@ -0,0 +1,262 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "d3761fdca3862892813e41388ac28a43", + "entities": [ + { + "tableName": "customscanners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "scannedpages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scanId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `filePath` TEXT NOT NULL, `originalScanSettings` TEXT NOT NULL, `rotation` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `outputName` TEXT DEFAULT null, PRIMARY KEY(`scanId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "scanId", + "columnName": "scanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerSessionId", + "columnName": "ownerSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalScanSettings", + "columnName": "originalScanSettings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rotation", + "columnName": "rotation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputName", + "columnName": "outputName", + "affinity": "TEXT", + "defaultValue": "null" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "scanId" + ] + }, + "indices": [ + { + "name": "index_scannedpages_ownerSessionId", + "unique": false, + "columnNames": [ + "ownerSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scannedpages_ownerSessionId` ON `${TABLE_NAME}` (`ownerSessionId`)" + }, + { + "name": "index_scannedpages_ownerSessionId_orderIndex", + "unique": true, + "columnNames": [ + "ownerSessionId", + "orderIndex" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scannedpages_ownerSessionId_orderIndex` ON `${TABLE_NAME}` (`ownerSessionId`, `orderIndex`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerSessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `currentScanSettings` TEXT, `currentSettingsUIData` TEXT DEFAULT null, `currentPage` INTEGER NOT NULL, PRIMARY KEY(`sessionId`))", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentScanSettings", + "columnName": "currentScanSettings", + "affinity": "TEXT" + }, + { + "fieldPath": "currentSettingsUIData", + "columnName": "currentSettingsUIData", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "currentPage", + "columnName": "currentPage", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + } + }, + { + "tableName": "tempfiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tempFileId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`tempFileId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tempFileId", + "columnName": "tempFileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerSessionId", + "columnName": "ownerSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tempFileId" + ] + }, + "indices": [ + { + "name": "index_tempfiles_ownerSessionId", + "unique": false, + "columnNames": [ + "ownerSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tempfiles_ownerSessionId` ON `${TABLE_NAME}` (`ownerSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerSessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "lastroute", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`route` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "route", + "columnName": "route", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "executedmigrationtoroom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`migrationId` TEXT NOT NULL, PRIMARY KEY(`migrationId`))", + "fields": [ + { + "fieldPath": "migrationId", + "columnName": "migrationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "migrationId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd3761fdca3862892813e41388ac28a43')" + ] + } +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt index 376e8f42..4c56adab 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt @@ -25,7 +25,7 @@ import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter entities = [ CustomScanner::class, ScannedPage::class, Session::class, TempFile::class, LastRoute::class, ExecutedMigrationToRoom::class ], - version = 4, + version = 5, autoMigrations = [ AutoMigration( from = 1, @@ -38,6 +38,10 @@ import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter AutoMigration( from = 3, to = 4 + ), + AutoMigration( + from = 4, + to = 5 ) ] ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt index 77601bb3..24210d04 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt @@ -1,5 +1,6 @@ package io.github.chrisimx.scanbridge.db.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -33,5 +34,7 @@ data class ScannedPage( val filePath: String, val originalScanSettings: ScanSettings, val rotation: ScanRelativeRotation = ScanRelativeRotation.Original, - val orderIndex: Int + val orderIndex: Int, + @ColumnInfo(defaultValue = "null") + val outputName: String? = null, ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index 97dd8739..5af9b8e6 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -2,9 +2,12 @@ package io.github.chrisimx.scanbridge.escl import io.github.chrisimx.esclkt.ESCLHttpCallResult import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.esclkt.JobState +import io.github.chrisimx.esclkt.ScanJob import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.scanbridge.model.HttpClientConfig +import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.ScanningError import io.github.chrisimx.scanbridge.model.UrlScannerHandle import io.github.chrisimx.scanbridge.model.toHttpClientConfig import io.github.chrisimx.scanbridge.ports.HttpClientFactory @@ -12,11 +15,12 @@ import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.ScanJobProcessingEvent import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import io.github.chrisimx.scanbridge.ports.ScanningProtocol -import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.ktor.http.Url import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow @@ -88,6 +92,61 @@ class EsclScanningProtocol( } } + private enum class PollResult { + Abort, + JobFinished, + ImagesReady, + TimedOut + } + + private suspend fun pollUntilImagesReady( + jobResult: ScanJob, + abortIfCancelling: suspend (ScanJob?) -> Boolean, + emit: suspend (ScanJobProcessingEvent) -> Unit + ): PollResult { + for (retries in 0..60) { + if (abortIfCancelling(jobResult)) { + return PollResult.Abort + } + + val status = jobResult.getJobStatus() + val isRunning = + status?.jobState == JobState.Processing || + status?.jobState == JobState.Pending + + val imagesToTransfer = status?.imagesToTransfer + + _logger.debug { + "Polling job status. Retry: $retries Result: $status imagesToTransfer: $imagesToTransfer isRunning: $isRunning" + } + + if (!isRunning) { + _logger.debug { "Job is reported to be not running anymore. jobRunning = false" } + + val deleteResult = jobResult.cancel() + _logger.debug { "Cancelling job after (a likely) failure: $deleteResult" } + + if (status?.jobState != JobState.Completed) { + _logger.warn { "Job info doesn't indicate completion: ${status?.jobState}" } + emit(ScanJobProcessingEvent.Failure( + ScanningError.JobCompletedInFailedState(status.toString()) + )) + } + + return PollResult.JobFinished + } + + if (imagesToTransfer != null && imagesToTransfer > 0u) { + _logger.debug { "There seem to be images to transfer. Breaking out of polling loop" } + return PollResult.ImagesReady + } + + delay(1000) + } + + return PollResult.TimedOut + } + override fun executeScanJob( handle: ScannerHandle, settings: ScannerConnectionSettings, @@ -98,7 +157,9 @@ class EsclScanningProtocol( handle as? UrlScannerHandle if (scannerUrlHandle == null) { - emit(ScanJobProcessingEvent.InvalidScannerHandle(handle)) + emit(ScanJobProcessingEvent.Failure( + ScanningError.InvalidScanHandle(handle) + )) return@flow } @@ -106,5 +167,142 @@ class EsclScanningProtocol( val httpClient = httpClientFactory.create(httpConfig) val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + + suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { + _logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } + scanJob?.cancel() + + emit(ScanJobProcessingEvent.Cancelled) + true + } else { + false + } + + if (abortIfCancelling()) return@flow + + _logger.debug { "Creating scan job. eSCLKt scan settings: $jobScanSettings" } + + val job = esclRequestClient.createJob(jobScanSettings) + + _logger.debug { "Creation request done. Result: $job" } + if (job !is ESCLRequestClient.ScannerCreateJobResult.Success) { + _logger.error { "Job creation failed. Result: $job" } + + emit( + ScanJobProcessingEvent.Failure(ScanningError.JobCreationFailed(job.toString())) + ) + return@flow + } + val jobResult = job.scanJob + + if (abortIfCancelling(jobResult)) return@flow + + var polling = false + + while (true) { + if (polling) { + val pollResult = pollUntilImagesReady(jobResult, ::abortIfCancelling, ::emit) + + when (pollResult) { + PollResult.Abort, + PollResult.JobFinished -> return@flow + + PollResult.ImagesReady -> { + // continue with transfer logic + } + + PollResult.TimedOut -> { + emit(ScanJobProcessingEvent.Failure(ScanningError.PollingTimedOut)) + return@flow + } + } + } + + if (abortIfCancelling(jobResult)) return@flow + + _logger.debug { "Retrieving next page" } + val nextPage = jobResult.retrieveNextPage() + _logger.debug { "Next page result: $nextPage" } + val status = jobResult.getJobStatus() + _logger.debug { "Retrieved job info: $status" } + // val jobStateString = status?.jobState.toJobStateString(application) + when (nextPage) { + is ESCLRequestClient.ScannerNextPageResult.NoFurtherPages -> { + _logger.debug { "Next page result is seen as no further pages. jobRunning = false" } + + if (status?.jobState != JobState.Completed) { + _logger.warn { "Job info doesn't indicate completion: $status" } + + emit(ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + )) + } + val deletionResult = jobResult.cancel() + _logger.debug { "Cancelling job after no further pages is reported: $deletionResult" } + return@flow + } + + is ESCLRequestClient.ScannerNextPageResult.RequestFailure -> { + if (nextPage.exception !is ESCLHttpCallResult.Error.HttpError) { + _logger.error { "Error while retrieving next page: $nextPage" } + emit(ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + )) + return@flow + } + val error = nextPage.exception as ESCLHttpCallResult.Error.HttpError + + if (status?.jobState == JobState.Completed) { + _logger.debug { "Job info indicates completion but response was not 404: $status" } + + emit(ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + )) + + val deletionResult = jobResult.cancel() + _logger.debug { "Cancelling job after non-standard completion: $deletionResult" } + return@flow + } else { + _logger.error { "Not successful code while retrieving next page: $nextPage" } + if (error.code == 503) { + // Retry with polling + _logger.debug { "503 error received. Retrying with polling" } + polling = true + continue + } else { + emit(ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + )) + val deletionResult = jobResult.cancel() + _logger.debug { + "Cancelling job after not successful response while trying to retrieve page: $deletionResult" + } + return@flow + } + } + } + + is ESCLRequestClient.ScannerNextPageResult.Success -> { + _logger.debug { "Received page." } + + val esclScannedPage = nextPage.page + val correspondingScannedPage = ScanProtocolScannedPage( + esclScannedPage.contentType, + esclScannedPage.data + ) + + emit(ScanJobProcessingEvent.NewPage(correspondingScannedPage)) + } + + else -> { + jobResult.cancel() + emit(ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + )) + return@flow + } + } + + } } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt new file mode 100644 index 00000000..5fe01516 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt @@ -0,0 +1,25 @@ +package io.github.chrisimx.scanbridge.model + +data class ScanProtocolScannedPage( + val contentType: String, + val data: ByteArray, +) { + override fun toString(): String = + "ScannedPage(contentType='$contentType', data.size=${data.size})" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ScanProtocolScannedPage) return false + + if (contentType != other.contentType) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = contentType.hashCode() + result = 31 * result + data.contentHashCode() + return result + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt new file mode 100644 index 00000000..0f5cbd13 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt @@ -0,0 +1,28 @@ +package io.github.chrisimx.scanbridge.model + +sealed class ScanningError(val unlocalizedMessage: String) { + data class UnsupportedContentType(val contentType: String) : ScanningError( + "Unsupported content type: $contentType" + ) + data class InvalidScanHandle(val handle: ScannerHandle) : ScanningError( + "Invalid scan handle: $handle" + ) + data class JobCreationFailed(val creationError: String) : ScanningError( + "Job creation failed: $creationError" + ) + + data class JobCompletedInFailedState(val jobStatus: String) : ScanningError( + "Job completed but failed. Job status: $jobStatus" + ) + + data object PollingTimedOut: ScanningError( + "Timed out waiting for images to transfer" + ) + + data class NextPageRetrievalError(val error: String, val jobStatus: String) : ScanningError( + "Error retrieving next page: $error. Job status: $jobStatus" + ) + + data class Other(val message: String) : ScanningError(message) + +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt index cd8dca0e..1a7b0b7f 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -3,7 +3,9 @@ package io.github.chrisimx.scanbridge.ports import io.github.chrisimx.esclkt.ESCLRequestClient import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.ScanningError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -25,10 +27,9 @@ sealed class ScannerCapabilitiesResult { } sealed class ScanJobProcessingEvent { - data class NewPage(val scannedPages: ESCLRequestClient.ScannedPage) : ScanJobProcessingEvent() - data class InvalidScannerHandle(val scannerHandle: ScannerHandle) : ScanJobProcessingEvent() + data class NewPage(val scannedPage: ScanProtocolScannedPage) : ScanJobProcessingEvent() data object Cancelled : ScanJobProcessingEvent() - data class Failure(val reason: Any) : ScanJobProcessingEvent() + data class Failure(val error: ScanningError) : ScanJobProcessingEvent() } interface ScanningProtocol { From 18aa85178f1c87e9547840468414c0a47aa5d41a Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 27 May 2026 18:50:57 +0200 Subject: [PATCH 09/38] Update to new eSCLKt version --- .../chrisimx/scanbridge/ScanSettings.kt | 2 +- .../data/model/LegacyESCLScanSettings.kt | 12 ++++---- .../ui/ScanSettingsComposableStateHolder.kt | 24 +++++++-------- .../data/ui/ScanningScreenViewModel.kt | 6 ++-- .../scanbridge/util/ESCLKtExtensions.kt | 30 +++++++++---------- core/build.gradle.kts | 3 ++ .../github/chrisimx/scanbridge/PaperFormat.kt | 6 ++-- .../chrisimx/scanbridge/ScanSettingsJson.kt | 10 +++---- .../scanbridge/escl/EsclScanningProtocol.kt | 2 +- gradle/libs.versions.toml | 7 +++-- 10 files changed, 54 insertions(+), 48 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index fefdf13f..25c4fba9 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -55,7 +55,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.SupportedResolutions -import io.github.chrisimx.esclkt.equalsLength +import io.github.chrisimx.csa.equalsLength import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit import io.github.chrisimx.scanbridge.model.NumberValidationResult diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt index a3eeb605..1dd6c8b9 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -1,18 +1,18 @@ package io.github.chrisimx.scanbridge.data.model +import io.github.chrisimx.csa.LengthUnit +import io.github.chrisimx.csa.millimeters import io.github.chrisimx.esclkt.BinaryRendering import io.github.chrisimx.esclkt.CcdChannelEnumOrRaw -import io.github.chrisimx.esclkt.ColorModeEnumOrRaw +import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw import io.github.chrisimx.esclkt.ContentTypeEnumOrRaw +import io.github.chrisimx.esclkt.EsclScanIntentEnumOrRaw import io.github.chrisimx.esclkt.FeedDirection import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.LengthUnit -import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw import io.github.chrisimx.esclkt.ScanRegion import io.github.chrisimx.esclkt.ScanRegions import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.scanbridge.util.toDoubleLocalized import kotlinx.serialization.Serializable @@ -47,14 +47,14 @@ data class StatelessImmutableScanRegion( @Serializable data class StatelessImmutableESCLScanSettingsState( val version: String, - val intent: ScanIntentEnumOrRaw?, + val intent: EsclScanIntentEnumOrRaw?, val scanRegions: StatelessImmutableScanRegion?, val documentFormatExt: String?, val contentType: ContentTypeEnumOrRaw?, val inputSource: InputSource?, val xResolution: UInt, val yResolution: UInt, - val colorMode: ColorModeEnumOrRaw?, + val colorMode: EsclColorModeEnumOrRaw?, val colorSpace: String?, val mediaType: String?, val ccdChannel: CcdChannelEnumOrRaw?, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 259258c4..965234c7 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -23,22 +23,22 @@ import android.app.Application import android.content.ClipData import android.content.ClipboardManager import android.content.Context.CLIPBOARD_SERVICE -import io.github.chrisimx.esclkt.ColorMode -import io.github.chrisimx.esclkt.ColorModeEnumOrRaw +import io.github.chrisimx.csa.CSAEnumOrRaw +import io.github.chrisimx.csa.LengthUnit +import io.github.chrisimx.csa.ThreeHundredthsOfInch +import io.github.chrisimx.csa.inches +import io.github.chrisimx.csa.millimeters +import io.github.chrisimx.csa.threeHundredthsOfInch import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EnumOrRaw +import io.github.chrisimx.esclkt.EsclColorMode +import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw +import io.github.chrisimx.esclkt.EsclScanIntentEnumOrRaw import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.LengthUnit -import io.github.chrisimx.esclkt.ScanIntentEnumOrRaw import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.esclkt.ThreeHundredthsOfInch import io.github.chrisimx.esclkt.getInputSourceCaps import io.github.chrisimx.esclkt.getInputSourceOptions -import io.github.chrisimx.esclkt.inches -import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.esclkt.scanRegion -import io.github.chrisimx.esclkt.threeHundredthsOfInch import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.model.Locale import io.github.chrisimx.scanbridge.model.NumberValidationResult @@ -292,9 +292,9 @@ class ScanSettingsComposableStateHolder( } } - fun setColorMode(colorMode: ColorModeEnumOrRaw?) { + fun setColorMode(colorMode: EsclColorModeEnumOrRaw?) { coroutineScope.launch { - if (colorMode is EnumOrRaw.Known && colorMode.value == ColorMode.BlackAndWhite1) { + if (colorMode is CSAEnumOrRaw.Known && colorMode.value == EsclColorMode.BlackAndWhite1) { Timber.d("Selecting b&w. Switching to PDF format") updateSettings { copy(colorMode = colorMode, documentFormat = "application/pdf", documentFormatExt = "application/pdf") @@ -367,7 +367,7 @@ class ScanSettingsComposableStateHolder( } } - fun setIntent(intent: ScanIntentEnumOrRaw?) { + fun setIntent(intent: EsclScanIntentEnumOrRaw?) { coroutineScope.launch { updateSettings { copy(intent = intent) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 88add2b1..879e895e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -37,16 +37,16 @@ import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image +import io.github.chrisimx.csa.inches +import io.github.chrisimx.csa.millimeters +import io.github.chrisimx.csa.threeHundredthsOfInch import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.ScanRegion import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities import io.github.chrisimx.esclkt.getInputSourceCaps import io.github.chrisimx.esclkt.getInputSourceOptions -import io.github.chrisimx.esclkt.inches -import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.esclkt.scanRegion -import io.github.chrisimx.esclkt.threeHundredthsOfInch import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService import io.github.chrisimx.scanbridge.datastore.appSettingsStore diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index e4c07d37..75b4846d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -22,10 +22,10 @@ package io.github.chrisimx.scanbridge.util import android.content.Context import android.icu.text.DecimalFormat import app.cash.paraphrase.getString -import io.github.chrisimx.esclkt.ColorMode -import io.github.chrisimx.esclkt.ColorModeEnumOrRaw +import io.github.chrisimx.csa.CSAEnumOrRaw import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EnumOrRaw +import io.github.chrisimx.esclkt.EsclColorMode +import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.esclkt.ScanSettings @@ -55,17 +55,17 @@ fun InputSource.toReadableString(context: Context): String = when (this) { InputSource.Camera -> context.getString(R.string.camera) } -fun ColorModeEnumOrRaw.localizedString(context: Context): String = when (this) { - is EnumOrRaw.Known -> when (this.value) { - ColorMode.BlackAndWhite1 -> context.getString(R.string.black_and_white) - ColorMode.RGB24 -> context.getString(FormattedResources.color_scan("24")) - ColorMode.RGB48 -> context.getString(FormattedResources.color_scan("48")) - ColorMode.AutoColorDetection -> context.getString(R.string.auto_detect) - ColorMode.Grayscale8 -> context.getString(FormattedResources.grayscale("8")) - ColorMode.Grayscale16 -> context.getString(FormattedResources.grayscale("16")) +fun EsclColorModeEnumOrRaw.localizedString(context: Context): String = when (this) { + is CSAEnumOrRaw.Known -> when (this.value) { + EsclColorMode.BlackAndWhite1 -> context.getString(R.string.black_and_white) + EsclColorMode.RGB24 -> context.getString(FormattedResources.color_scan("24")) + EsclColorMode.RGB48 -> context.getString(FormattedResources.color_scan("48")) + EsclColorMode.AutoColorDetection -> context.getString(R.string.auto_detect) + EsclColorMode.Grayscale8 -> context.getString(FormattedResources.grayscale("8")) + EsclColorMode.Grayscale16 -> context.getString(FormattedResources.grayscale("16")) } - is EnumOrRaw.Unknown -> this.asString() + is CSAEnumOrRaw.Unknown -> this.asString() } fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteResolution { @@ -77,12 +77,12 @@ fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteReso return maxResolution } -fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): ColorModeEnumOrRaw? { +fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): EsclColorModeEnumOrRaw? { val inputCaps = this.getInputSourceCaps(inputSource) val chosenColorMode = inputCaps.settingProfiles.elementAtOrNull(0)?.colorModes?.maxByOrNull { when (it) { - is EnumOrRaw.Known -> it.value.ordinal - is EnumOrRaw.Unknown -> 0 + is CSAEnumOrRaw.Known -> it.value.ordinal + is CSAEnumOrRaw.Unknown -> 0 } } return chosenColorMode diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 3e5331bf..15542788 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -56,6 +56,9 @@ kotlin { api(libs.ktor.client.core) api(libs.ktor.logging) api(libs.esclkt) + api(libs.wsdkt) + api(libs.commonScannerAbstractions) + api(libs.nonThrowingKtor) // Room deps implementation(libs.androidx.room.runtime) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt index 411cd7d4..344a498a 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt @@ -1,8 +1,8 @@ package io.github.chrisimx.scanbridge -import io.github.chrisimx.esclkt.LengthUnit -import io.github.chrisimx.esclkt.Millimeters -import io.github.chrisimx.esclkt.millimeters +import io.github.chrisimx.csa.LengthUnit +import io.github.chrisimx.csa.Millimeters +import io.github.chrisimx.csa.millimeters import kotlinx.serialization.Serializable @Serializable diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt index 7a320eac..e91a5ea2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt @@ -1,10 +1,10 @@ package io.github.chrisimx.scanbridge -import io.github.chrisimx.esclkt.Inches -import io.github.chrisimx.esclkt.LengthUnit -import io.github.chrisimx.esclkt.Millimeters -import io.github.chrisimx.esclkt.Points -import io.github.chrisimx.esclkt.ThreeHundredthsOfInch +import io.github.chrisimx.csa.Inches +import io.github.chrisimx.csa.LengthUnit +import io.github.chrisimx.csa.Millimeters +import io.github.chrisimx.csa.Points +import io.github.chrisimx.csa.ThreeHundredthsOfInch import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index 5af9b8e6..44b2ce08 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -1,7 +1,7 @@ package io.github.chrisimx.scanbridge.escl -import io.github.chrisimx.esclkt.ESCLHttpCallResult import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.safektor.ESCLHttpCallResult import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.esclkt.ScanJob import io.github.chrisimx.esclkt.ScanSettings diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 93292090..5d5d8a97 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ concurrentFutures = "1.2.0" constraintlayoutCompose = "1.1.1" converterGson = "2.9.0" datastore = "1.2.0" -esclkt = "2.0.8-SNAPSHOT" +anyscankt = "2.0.8-SNAPSHOT" escl-mock-server = "1.0.1" itextCore = "9.3.0" kotlin = "2.3.20-Beta1" @@ -96,7 +96,10 @@ androidx-material-icons-core = { module = "androidx.compose.material:material-ic androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-fetcher-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } -esclkt = { module = "io.github.chrisimx:esclkt", version.ref = "esclkt" } +esclkt = { module = "io.github.chrisimx:esclkt", version.ref = "anyscankt" } +wsdkt = { module = "io.github.chrisimx:wsdkt", version.ref = "anyscankt" } +commonScannerAbstractions = { module = "io.github.chrisimx:common-scanner-abstractions", version.ref = "anyscankt" } +nonThrowingKtor = { module = "io.github.chrisimx:nonthrowingktor", version.ref = "anyscankt" } itext7-core = { module = "com.itextpdf:itext-core", version.ref = "itextCore" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } From 0cbdf164c5ada62f368592dde5e19b1b32e9ab59 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 11 Jul 2026 11:16:15 +0200 Subject: [PATCH 10/38] Partially refactor scanning functionality to use unified abstractions --- .../chrisimx/scanbridge/ScanSettings.kt | 67 ++--- .../chrisimx/scanbridge/ScanningScreen.kt | 3 +- .../ScanJobForegroundService.kt | 4 +- .../data/model/LegacyESCLScanSettings.kt | 4 +- .../ui/ScanSettingsComposableStateHolder.kt | 231 +++++++++++------- .../scanbridge/data/ui/ScanningScreenData.kt | 6 +- .../data/ui/ScanningScreenViewModel.kt | 125 ++-------- .../scanbridge/datastore/DataStores.kt | 4 +- .../AppSettingsMigrationV0To1.kt | 79 ++++++ .../stores/DefaultScanSettingsStore.kt | 12 +- .../scanbridge/util/ESCLKtExtensions.kt | 113 +++++++-- .../scanbridge/util/UIInputSourceType.kt | 14 ++ androidApp/src/main/proto/app_settings.proto | 1 + core/build.gradle.kts | 2 +- .../github/chrisimx/scanbridge/PaperFormat.kt | 6 +- .../chrisimx/scanbridge/ScanSettingsJson.kt | 10 +- .../chrisimx/scanbridge/db/ScanBridgeDb.kt | 9 +- .../chrisimx/scanbridge/db/daos/SessionDao.kt | 4 +- .../scanbridge/db/entities/ScannedPage.kt | 4 +- .../scanbridge/db/entities/Session.kt | 8 +- .../CommonScanSettingsTypeConverter.kt | 7 + .../JsonSerializationTypeConverter.kt | 24 ++ .../ScanSettingsTypeConverter.kt | 17 +- .../ScanSettingsUiDataTypeConverter.kt | 26 +- .../scanbridge/escl/EsclScanningProtocol.kt | 34 ++- .../chrisimx/scanbridge/model/ScanJob.kt | 4 +- .../model/ScanSettingsEnterableData.kt | 14 +- .../scanbridge/ports/ScanningProtocol.kt | 9 +- gradle/libs.versions.toml | 2 +- 29 files changed, 510 insertions(+), 333 deletions(-) create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIInputSourceType.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 25c4fba9..d5ba8632 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -53,9 +53,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.SupportedResolutions -import io.github.chrisimx.csa.equalsLength +import io.github.chrisimx.anyscan.DiscreteResolution +import io.github.chrisimx.anyscan.equalsLength import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit import io.github.chrisimx.scanbridge.model.NumberValidationResult @@ -101,6 +100,11 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp val scanSettings by scanSettingsStateHolder.scanSettings.collectAsState() + val selectedInputSource by scanSettingsStateHolder.selectedInputSource.collectAsState() + val duplexUsed by scanSettingsStateHolder.duplexUsed.collectAsState() + + val selectedIntent by scanSettingsStateHolder.currentIntent.collectAsState() + val scrollState = rememberScrollState() Column( @@ -125,7 +129,7 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp count = inputSourceOptions.size ), onClick = { scanSettingsStateHolder.setInputSource(inputSource) }, - selected = scanSettings.inputSource == inputSource + selected = selectedInputSource == inputSource ) { Text(inputSource.toReadableString(context)) } @@ -133,7 +137,7 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp } ToggleButton( enabled = duplexCurrentlyAvailable, - checked = scanSettings.duplex == true, + checked = duplexUsed, onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } ) { Text(stringResource(R.string.setting_duplex)) } } @@ -155,16 +159,19 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp onViewChosen = { fitsRowVersion = it } ) - SelectionCardWithDefault( - stringResource(R.string.intent), - intentOptions, - { - scanSettingsStateHolder.setIntent(it) - }, - { this.asString() }, - scanSettings.intent, - fitsRowVersion - ) + if (intentOptions.isNotEmpty()) { + SelectionCardWithDefault( + stringResource(R.string.intent), + intentOptions, + { + scanSettingsStateHolder.setIntent(it) + }, + { this.asString() }, + selectedIntent, + fitsRowVersion + ) + } + SelectionCardWithDefault( stringResource(R.string.color_mode), @@ -200,7 +207,7 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp paperFormat.width, paperFormat.height ) - Timber.tag(TAG).d("New region state: ${scanSettings.scanRegions}") + Timber.tag(TAG).d("New region state: ${scanSettings.scanArea}") }, label = { Text(paperFormat.name) }, selected = !vmData.customMenuEnabled && !vmData.maximumSize && @@ -316,28 +323,28 @@ private fun SelectionCardWithDefault( @Composable private fun ResolutionSettingButtonRowVersion( - supportedResolutions: SupportedResolutions, + supportedResolutions: List, currentResolution: DiscreteResolution?, setSelectedResolution: (UInt, UInt) -> Unit ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.resolution_dpi)) SingleChoiceSegmentedButtonRow { - supportedResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> + supportedResolutions.forEachIndexed { index, discreteResolution -> SegmentedButton( shape = SegmentedButtonDefaults.itemShape( index = index, - count = supportedResolutions.discreteResolutions.size + count = supportedResolutions.size ), onClick = { - setSelectedResolution(discreteResolution.xResolution, discreteResolution.yResolution) + setSelectedResolution(discreteResolution.widthDPI, discreteResolution.heightDPI) }, selected = currentResolution == discreteResolution ) { - if (discreteResolution.xResolution == discreteResolution.yResolution) { - Text("${discreteResolution.xResolution}") + if (discreteResolution.widthDPI == discreteResolution.heightDPI) { + Text("${discreteResolution.widthDPI}") } else { - Text("${discreteResolution.xResolution}x${discreteResolution.yResolution}") + Text("${discreteResolution.widthDPI}x${discreteResolution.heightDPI}") } } } @@ -347,7 +354,7 @@ private fun ResolutionSettingButtonRowVersion( @Composable private fun ResolutionSettingCardVersion( - supportedResolutions: SupportedResolutions, + supportedResolutions: List, currentResolution: DiscreteResolution?, setSelectedResolution: (UInt, UInt) -> Unit ) { @@ -368,17 +375,17 @@ private fun ResolutionSettingCardVersion( horizontalArrangement = Arrangement.SpaceEvenly ) { - supportedResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> - val text = if (discreteResolution.xResolution == discreteResolution.yResolution) { - "${discreteResolution.xResolution}" + supportedResolutions.forEach { discreteResolution -> + val text = if (discreteResolution.widthDPI == discreteResolution.heightDPI) { + "${discreteResolution.widthDPI}" } else { - "${discreteResolution.xResolution}x${discreteResolution.yResolution}" + "${discreteResolution.widthDPI}x${discreteResolution.heightDPI}" } InputChip( onClick = { setSelectedResolution( - discreteResolution.xResolution, - discreteResolution.yResolution + discreteResolution.widthDPI, + discreteResolution.heightDPI ) }, label = { Text(text) }, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 7e535242..17163380 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -108,6 +108,7 @@ import io.github.chrisimx.scanbridge.util.clearAndNavigateTo import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage import io.github.chrisimx.scanbridge.util.toReadableString +import io.github.chrisimx.scanbridge.util.toUIInputSourceType import java.io.File import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope @@ -653,7 +654,7 @@ fun ScanContent( if (currentPages.size > pagerState.currentPage) { Text( - currentPage?.originalScanSettings?.inputSource?.toReadableString( + currentPage?.originalScanSettings?.inputSource?.toUIInputSourceType()?.toReadableString( context ).toString() ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt index b742b253..4565952d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt @@ -11,7 +11,7 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.room.immediateTransaction import androidx.room.useWriterConnection -import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.MainActivity import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.db.ScanBridgeDb @@ -226,7 +226,7 @@ class ScanJobForegroundService : Service() { scanJobs.notifyCompleted(scanJob) } - suspend fun addScan(sessionID: Uuid, path: String, settings: ScanSettings, rotation: ScanRelativeRotation, fileName: String? = null) { + suspend fun addScan(sessionID: Uuid, path: String, settings: CommonScanSettings, rotation: ScanRelativeRotation, fileName: String? = null) { Timber.d("Adding scan: $path, $rotation") db.useWriterConnection { it.immediateTransaction { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt index 1dd6c8b9..5ace762d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -1,7 +1,7 @@ package io.github.chrisimx.scanbridge.data.model -import io.github.chrisimx.csa.LengthUnit -import io.github.chrisimx.csa.millimeters +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.millimeters import io.github.chrisimx.esclkt.BinaryRendering import io.github.chrisimx.esclkt.CcdChannelEnumOrRaw import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 965234c7..15741466 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -23,30 +23,30 @@ import android.app.Application import android.content.ClipData import android.content.ClipboardManager import android.content.Context.CLIPBOARD_SERVICE -import io.github.chrisimx.csa.CSAEnumOrRaw -import io.github.chrisimx.csa.LengthUnit -import io.github.chrisimx.csa.ThreeHundredthsOfInch -import io.github.chrisimx.csa.inches -import io.github.chrisimx.csa.millimeters -import io.github.chrisimx.csa.threeHundredthsOfInch -import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EsclColorMode -import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw -import io.github.chrisimx.esclkt.EsclScanIntentEnumOrRaw -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.InputSourceCaps -import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.esclkt.getInputSourceCaps -import io.github.chrisimx.esclkt.getInputSourceOptions -import io.github.chrisimx.esclkt.scanRegion +import io.github.chrisimx.anyscan.Area +import io.github.chrisimx.anyscan.AnyScanEnumOrRaw +import io.github.chrisimx.anyscan.ColorMode +import io.github.chrisimx.anyscan.CommonInputSourceCaps +import io.github.chrisimx.anyscan.CommonInputSourceType +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.DiscreteResolution +import io.github.chrisimx.anyscan.FileFormat +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.ScanIntent +import io.github.chrisimx.anyscan.ThreeHundredthsOfInch +import io.github.chrisimx.anyscan.asEnumOrRaw +import io.github.chrisimx.anyscan.inches +import io.github.chrisimx.anyscan.millimeters +import io.github.chrisimx.anyscan.threeHundredthsOfInch import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.model.Locale import io.github.chrisimx.scanbridge.model.NumberValidationResult -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.ports.LocaleProvider +import io.github.chrisimx.scanbridge.util.UIInputSourceType import io.github.chrisimx.scanbridge.util.derived -import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.toDoubleLocalized +import io.github.chrisimx.scanbridge.util.toUIInputSourceType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -61,6 +61,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.zip import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam import timber.log.Timber @@ -72,11 +73,11 @@ enum class ScanSettingsLengthUnit { class ScanSettingsComposableStateHolder( @InjectedParam - val scanSettings: StateFlow, + val scanSettings: StateFlow, @InjectedParam - private val initialScanSettingsData: ScanSettingsEnterableData, + private val initialScanSettingsData: ScanSettingsEnterableDataV1, @InjectedParam - private val updateSettings: suspend (ScanSettings.() -> ScanSettings) -> Unit, + private val updateSettings: suspend (CommonScanSettings.() -> CommonScanSettings) -> Unit, @InjectedParam private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, @@ -84,28 +85,40 @@ class ScanSettingsComposableStateHolder( ) { private val _uiState = MutableStateFlow(initialScanSettingsData) - val uiState: StateFlow = _uiState.asStateFlow() + val uiState: StateFlow = _uiState.asStateFlow() - val inputSourceOptions: StateFlow> = _uiState.derived(coroutineScope) { - it.capabilities.getInputSourceOptions() + val inputSourceOptions: StateFlow> = _uiState.derived(coroutineScope) { + it.capabilities.inputSources.map { + it.inputSourceType.toUIInputSourceType() + }.distinct() + } + + val selectedInputSource: StateFlow = scanSettings.derived(coroutineScope) { + it.inputSource?.toUIInputSourceType() ?: inputSourceOptions.value.first() + } + + val duplexUsed: StateFlow = scanSettings.derived(coroutineScope) { + it.inputSource == CommonInputSourceType.ADF_DUPLEX } val duplexAdfSupported: StateFlow = _uiState.derived(coroutineScope) { - it.capabilities.adf?.duplexCaps != null + it.capabilities.inputSources.firstOrNull { + it.inputSourceType == CommonInputSourceType.ADF_DUPLEX + } != null } val duplexCurrentlyAvailable: StateFlow = combine(duplexAdfSupported, scanSettings) { duplexSupport, scanSettings -> - duplexSupport && scanSettings.inputSource == InputSource.Feeder + duplexSupport && scanSettings.inputSource == CommonInputSourceType.ADF_DUPLEX }.stateIn(coroutineScope, SharingStarted.Lazily, false) - private val selectedInputSourceCaps: StateFlow = combine(scanSettings, _uiState) { settings, uiState -> - uiState.capabilities.getInputSourceCaps(settings.inputSource, settings.duplex ?: false) + private val selectedInputSourceCaps: StateFlow = combine(scanSettings, _uiState) { settings, uiState -> + uiState.capabilities.inputSources.first { + it.inputSourceType == settings.inputSource + } }.stateIn( coroutineScope, SharingStarted.Lazily, - uiState.value.capabilities.getInputSourceOptions().first().let { - uiState.value.capabilities.getInputSourceCaps(it, scanSettings.value.duplex == true) - } + uiState.value.capabilities.inputSources.first() ) val intentOptions = selectedInputSourceCaps.derived(coroutineScope) { @@ -113,11 +126,11 @@ class ScanSettingsComposableStateHolder( } val supportedScanResolutions = selectedInputSourceCaps.derived(coroutineScope) { - it.settingProfiles[0].supportedResolutions + it.supportedResolutions } val supportedColorModes = selectedInputSourceCaps.derived(coroutineScope) { - it.settingProfiles.firstOrNull()?.colorModes ?: listOf() + it.supportedColorModes } val currentColorMode = scanSettings.derived(coroutineScope) { @@ -125,10 +138,7 @@ class ScanSettingsComposableStateHolder( } val currentResolution: StateFlow = scanSettings.derived(coroutineScope) { - val x = it.xResolution - val y = it.yResolution - - if (x != null && y != null) DiscreteResolution(x, y) else null + return@derived it.resolution } val lengthUnit = localeProvider.locale.derived(coroutineScope) { @@ -144,17 +154,33 @@ class ScanSettingsComposableStateHolder( } val currentScanRegion = scanSettings.derived(coroutineScope) { - it.scanRegions?.regions?.firstOrNull() + it.scanArea + } + + val currentIntent = scanSettings.derived(coroutineScope) { + it.scanIntent } val heightValidationResult = combine(currentHeightText, lengthUnit, selectedInputSourceCaps) { heightText, unit, inputSourceCaps -> - return@combine validateCustomLengthInput(heightText, unit, inputSourceCaps.maxHeight, inputSourceCaps.minHeight) + val maxHeight = inputSourceCaps.maxSize.height + val minHeight = inputSourceCaps.minSize.height + return@combine validateCustomLengthInput( + lengthText = heightText, + unit = unit, + max = maxHeight.toThreeHundredthsOfInch(), + min = minHeight.toThreeHundredthsOfInch()) }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) val widthValidationResult = combine(currentWidthText, lengthUnit, selectedInputSourceCaps) { widthText, unit, inputSourceCaps -> - return@combine validateCustomLengthInput(widthText, unit, inputSourceCaps.maxWidth, inputSourceCaps.minWidth) + val maxWidth = inputSourceCaps.maxSize.width + val minWidth = inputSourceCaps.minSize.width + return@combine validateCustomLengthInput( + lengthText = widthText, + unit = unit, + max = maxWidth.toThreeHundredthsOfInch(), + min = minWidth.toThreeHundredthsOfInch()) }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) private fun validateCustomLengthInput( @@ -206,12 +232,7 @@ class ScanSettingsComposableStateHolder( Timber.d("Maximum size flag set to $maxSize: This means we should set scanRegion to maximum") updateSettings { copy( - scanRegions = scanRegion { - width = inputSourceCaps.maxWidth - height = inputSourceCaps.maxHeight - xOffset = 0.millimeters() - yOffset = 0.millimeters() - } + scanArea = inputSourceCaps.maxSize ) } }.launchIn(coroutineScope) @@ -221,27 +242,28 @@ class ScanSettingsComposableStateHolder( widthValidationResult .filterIsInstance() .distinctUntilChanged() - .onEach { widthValidationResult -> + .zip(selectedInputSourceCaps) { widthValidationResult, inputSourceCaps -> Pair(widthValidationResult, inputSourceCaps) } + .onEach { (widthValidationResult, inputSourceCaps) -> updateSettings { - val currentScanRegion = scanRegions?.regions?.firstOrNull() + val currentScanRegion = scanArea Timber.d("Width validation success result received: $widthValidationResult") if (currentScanRegion == null) { Timber.d("Width validation success and current scanRegion null, replacing!") return@updateSettings copy( - scanRegions = scanRegion { - maxHeight() + scanArea = Area( + height = inputSourceCaps.maxSize.height, width = widthValidationResult.value.threeHundredthsOfInch() - } + ) ) } else { Timber.d("Width validation success and current scanRegion not null, reusing!") val currentHeight = currentScanRegion.height return@updateSettings copy( - scanRegions = scanRegion { - width = widthValidationResult.value.threeHundredthsOfInch() + scanArea = Area( + width = widthValidationResult.value.threeHundredthsOfInch(), height = currentHeight - } + ) ) } } @@ -256,27 +278,28 @@ class ScanSettingsComposableStateHolder( } .filterIsInstance() .distinctUntilChanged() - .onEach { heightValidationResult -> + .zip(selectedInputSourceCaps) { heightValidationResult, inputSourceCaps -> Pair(heightValidationResult, inputSourceCaps) } + .onEach { (heightValidationResult, inputSource) -> Timber.d("Height validation success result received: $heightValidationResult") updateSettings { - val currentScanRegion = scanRegions?.regions?.firstOrNull() + val currentScanRegion = scanArea if (currentScanRegion == null) { Timber.d("Height validation success and current scanRegion null, replacing!") return@updateSettings copy( - scanRegions = scanRegion { - maxWidth() + scanArea = Area( + width = inputSource.maxSize.width, height = heightValidationResult.value.threeHundredthsOfInch() - } + ) ) } else { Timber.d("Height validation success and current scanRegion not null, reusing!") val currentWidth = currentScanRegion.width return@updateSettings copy( - scanRegions = scanRegion { - width = currentWidth + scanArea = Area( + width = currentWidth, height = heightValidationResult.value.threeHundredthsOfInch() - } + ) ) } } @@ -285,41 +308,61 @@ class ScanSettingsComposableStateHolder( } fun setDuplex(duplex: Boolean) { + val isDuplexCurrentlyAvailable = duplexCurrentlyAvailable.value + + if (duplex && !isDuplexCurrentlyAvailable) { + Timber.d("Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyAvailable") + return + } + + val newInputSource = if (duplex) { + CommonInputSourceType.ADF_DUPLEX + } else { + CommonInputSourceType.ADF_SIMPLEX + } + coroutineScope.launch { updateSettings { - copy(duplex = duplex) + copy(inputSource = newInputSource) } } } - fun setColorMode(colorMode: EsclColorModeEnumOrRaw?) { + fun setColorMode(colorMode: AnyScanEnumOrRaw?) { coroutineScope.launch { - if (colorMode is CSAEnumOrRaw.Known && colorMode.value == EsclColorMode.BlackAndWhite1) { + if (colorMode is AnyScanEnumOrRaw.Known && colorMode.value == ColorMode.BlackAndWhite1) { Timber.d("Selecting b&w. Switching to PDF format") updateSettings { - copy(colorMode = colorMode, documentFormat = "application/pdf", documentFormatExt = "application/pdf") + copy(colorMode = colorMode, + format = FileFormat.PDF.asEnumOrRaw()) } } else { Timber.d("Selecting a color mode not b&w. Using jpeg again") updateSettings { - copy(colorMode = colorMode, documentFormat = "image/jpeg", documentFormatExt = "image/jpeg") + copy(colorMode = colorMode, format = FileFormat.JPEG.asEnumOrRaw()) } } } } - fun setInputSource(inputSource: InputSource) { + fun setInputSource(inputSource: UIInputSourceType) { Timber.d("Input Source being set to $inputSource. Validating existing settings.") + + val newInputSource = when (inputSource) { + UIInputSourceType.PLATEN -> CommonInputSourceType.PLATEN + UIInputSourceType.ADF -> CommonInputSourceType.ADF_SIMPLEX + } + coroutineScope.launch { updateSettings { - val currentScanSettings = scanSettings.value + //val currentScanSettings = scanSettings.value val uiState = uiState.value - val inputSourceCaps = uiState.capabilities.getInputSourceCaps(inputSource, currentScanSettings.duplex == true) + val inputSourceCaps = uiState.capabilities.getInputSourceCaps(newInputSource)!! - val supportedResolutions = inputSourceCaps.settingProfiles[0].supportedResolutions.discreteResolutions + val supportedResolutions = inputSourceCaps.supportedResolutions - val xRes = currentScanSettings.xResolution - val yRes = currentScanSettings.yResolution + val xRes = this.resolution?.widthDPI + val yRes = this.resolution?.heightDPI Timber.d("Input source being set. Current Resolution is: $xRes x $yRes") @@ -327,30 +370,34 @@ class ScanSettingsComposableStateHolder( !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) val replacementResolution = if (invalidResolutionSetting) { - val highestScanResolution = uiState.capabilities.getMaxResolution(inputSource) + val highestScanResolution = inputSourceCaps.supportedResolutions + .maxBy { it.widthDPI + it.heightDPI } - Pair(highestScanResolution.xResolution, highestScanResolution.yResolution) + DiscreteResolution(widthDPI = highestScanResolution.widthDPI, heightDPI = highestScanResolution.heightDPI) } else { - Pair(xRes, yRes) + if (xRes != null && yRes != null) { + DiscreteResolution(widthDPI = xRes, heightDPI = yRes) + } else { + null + } } - val intentSupported = currentScanSettings.intent?.let { inputSourceCaps.supportedIntents.contains(it) } ?: true + val intentSupported = this.scanIntent?.let { inputSourceCaps.supportedIntents.contains(it) } ?: true val replacementIntent = if (intentSupported) { - currentScanSettings.intent + this.scanIntent } else { null } Timber.d( "Input Source being set to $inputSource. " + - "Validated existing settings to: Res: ${replacementResolution.first} x ${replacementResolution.second}, Intent: $replacementIntent" + "Validated existing settings to: Res: ${replacementResolution} Intent: $replacementIntent" ) copy( - inputSource = inputSource, - xResolution = replacementResolution.first, - yResolution = replacementResolution.second, - intent = replacementIntent + inputSource = newInputSource, + resolution = replacementResolution, + scanIntent = replacementIntent ) } } @@ -360,17 +407,19 @@ class ScanSettingsComposableStateHolder( coroutineScope.launch { updateSettings { copy( - xResolution = xResolution, - yResolution = yResolution + resolution = DiscreteResolution( + widthDPI = xResolution, + heightDPI = yResolution + ) ) } } } - fun setIntent(intent: EsclScanIntentEnumOrRaw?) { + fun setIntent(intent: AnyScanEnumOrRaw?) { coroutineScope.launch { updateSettings { - copy(intent = intent) + copy(scanIntent = intent) } } } @@ -425,12 +474,10 @@ class ScanSettingsComposableStateHolder( coroutineScope.launch { updateSettings { copy( - scanRegions = scanRegion { - width = newWidth + scanArea = Area( + width = newWidth, height = newHeight - xOffset = 0.millimeters() - yOffset = 0.millimeters() - } + ) ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index 6b0c5320..dd719756 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -23,7 +23,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.anyscan.CommonScannerCapabilities import java.io.File import kotlin.uuid.Uuid @@ -35,7 +35,7 @@ data class ScanningScreenData( val confirmPageDeleteDialogShown: MutableState = mutableStateOf(false), val error: MutableState = mutableStateOf(null), val scanSettingsVM: MutableState = mutableStateOf(null), - val capabilities: MutableState = mutableStateOf(null), + val capabilities: MutableState = mutableStateOf(null), val scanSettingsMenuOpen: MutableState = mutableStateOf(false), val showExportOptions: MutableState = mutableStateOf(false), val showSaveOptions: MutableState = mutableStateOf(false), @@ -69,7 +69,7 @@ data class ImmutableScanningScreenData( private val confirmPageDeleteDialogShownState: State, private val errorState: State, private val scanSettingsVMState: State, - private val capabilitiesState: State, + private val capabilitiesState: State, private val scanSettingsMenuOpenState: State, private val showExportOptionsState: State, private val showSaveOptionsState: State, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 879e895e..4acf884f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -37,16 +37,13 @@ import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image -import io.github.chrisimx.csa.inches -import io.github.chrisimx.csa.millimeters -import io.github.chrisimx.csa.threeHundredthsOfInch +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.inches +import io.github.chrisimx.anyscan.millimeters +import io.github.chrisimx.anyscan.threeHundredthsOfInch import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.ScanRegion -import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.esclkt.ScannerCapabilities -import io.github.chrisimx.esclkt.getInputSourceCaps -import io.github.chrisimx.esclkt.getInputSourceOptions -import io.github.chrisimx.esclkt.scanRegion import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService import io.github.chrisimx.scanbridge.datastore.appSettingsStore @@ -56,7 +53,7 @@ import io.github.chrisimx.scanbridge.db.entities.Session import io.github.chrisimx.scanbridge.db.entities.TempFile import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanRelativeRotation -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.scannerCapabilities import io.github.chrisimx.scanbridge.model.toggleRotation @@ -66,6 +63,7 @@ import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull 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.coerceIn import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.rotateBy90 @@ -282,18 +280,18 @@ class ScanningScreenViewModel( } } - suspend fun saveUpdatedScanSettingsUiData(newData: ScanSettingsEnterableData?) { + suspend fun saveUpdatedScanSettingsUiData(newData: ScanSettingsEnterableDataV1?) { Timber.d("Settings ui data updated $newData") sessionDao.updateScanSettingsUiData(sessionID, newData) } - suspend fun setScannerCapabilities(caps: ScannerCapabilities) { + suspend fun setScannerCapabilities(caps: CommonScannerCapabilities) { _scanningScreenData.capabilities.value = caps val storedSession = sessionDao.getSessionById(sessionID) Timber.d("Stored session: $storedSession") - val updateSettings: suspend (ScanSettings.() -> ScanSettings) -> Unit = { lambda -> + val updateSettings: suspend (CommonScanSettings.() -> CommonScanSettings) -> Unit = { lambda -> db.useWriterConnection { it.immediateTransaction { val oldSession = sessionDao.getSessionById(sessionID) ?: return@immediateTransaction @@ -306,7 +304,7 @@ class ScanningScreenViewModel( } } - val defaultScanSettingsUIData = ScanSettingsEnterableData( + val defaultScanSettingsUIData = ScanSettingsEnterableDataV1( caps ) @@ -326,102 +324,9 @@ class ScanningScreenViewModel( val (savedSettings, savedSettingsUiState) = savedSettingsPair val initialSettings = if (savedSettings != null) { try { - // Validate that the saved input source is still supported - val supportedInputSources = caps.getInputSourceOptions() - val validatedInputSource = if (savedSettings.inputSource != null && - !supportedInputSources.contains(savedSettings.inputSource) - ) { - val fallbackInputSource = supportedInputSources.firstOrNull() ?: InputSource.Platen - Timber.w( - "Saved input source ${savedSettings.inputSource} not supported by current scanner," + - " falling back to default $fallbackInputSource" - ) - fallbackInputSource - } else { - savedSettings.inputSource - } - - // Validate duplex setting - only allow if ADF supports duplex - val duplex = if (savedSettings.duplex == true && - (savedSettings.inputSource != InputSource.Feeder || caps.adf?.duplexCaps == null) - ) { - Timber.w("Duplex not supported with current input source, disabling duplex") - false - } else { - savedSettings.duplex - } - - val selectedInputSourceCaps = caps.getInputSourceCaps( - validatedInputSource ?: caps.getInputSourceOptions().first(), - duplex ?: false - ) - - val intent = if (savedSettings.intent != null && - !selectedInputSourceCaps.supportedIntents.contains(savedSettings.intent) - ) { - val firstSupportedIntent = selectedInputSourceCaps.supportedIntents.first() - Timber.w( - "Intent not supported with current input source," + - " using first supported intent: $firstSupportedIntent" - ) - firstSupportedIntent - } else { - savedSettings.intent - } + val coercedSettings = savedSettings.coerceIn(caps) - val savedScanRegion = savedSettings.scanRegions?.regions?.firstOrNull() - val scanRegion = if (savedScanRegion != null) { - Timber.d("There is a saved scan region: $savedScanRegion") - val storedWidthThreeHOfInch = savedScanRegion.width.value - val storedHeightThreeHOfInch = savedScanRegion.height.value - - // Calculate max/min lengths with tolerances - val tolerance = 3 - - val realMaxWidth = selectedInputSourceCaps.maxWidth.toThreeHundredthsOfInch().value.toInt() - val realMinWidth = selectedInputSourceCaps.minWidth.toThreeHundredthsOfInch().value.toInt() - val realMaxHeight = selectedInputSourceCaps.maxHeight.toThreeHundredthsOfInch().value.toInt() - val realMinHeight = selectedInputSourceCaps.minHeight.toThreeHundredthsOfInch().value.toInt() - - val minWidth = (realMinWidth - tolerance).coerceAtLeast(0) - val maxWidth = realMaxWidth + tolerance - - val minHeight = (realMinHeight - tolerance).coerceAtLeast(0) - val maxHeight = realMaxHeight + tolerance - - val width = storedWidthThreeHOfInch.toInt() - .coerceIn(minWidth..maxWidth) - .toUInt() - - val height = storedHeightThreeHOfInch.toInt() - .coerceIn(minHeight..maxHeight) - .toUInt() - - val xOffset = savedScanRegion.xOffset - val yOffset = savedScanRegion.yOffset - val coercedScanRegion = scanRegion(selectedInputSourceCaps) { - this.width = width.threeHundredthsOfInch() - this.height = height.threeHundredthsOfInch() - this.xOffset = xOffset - this.yOffset = yOffset - } - Timber.d( - "After coercing we have the scan region: $coercedScanRegion " + - "(maxWidth: $maxWidth, minWidth: $minWidth, maxHeight: $maxHeight, minHeight: $minHeight)" - ) - coercedScanRegion - } else { - null - } - - val validatedSettings = savedSettings.copy( - inputSource = validatedInputSource, - duplex = duplex, - intent = intent, - scanRegions = scanRegion - ) - - validatedSettings + coercedSettings } catch (e: Exception) { Timber.e(e, "Error applying saved settings, using defaults") caps.calculateDefaultESCLScanSettingsState() @@ -430,13 +335,15 @@ class ScanningScreenViewModel( caps.calculateDefaultESCLScanSettingsState() } + val savedSettingsUiStateWithCaps = savedSettingsUiState?.copy(capabilities = caps) + sessionDao.insertAll(Session(sessionID, initialSettings, savedSettingsUiState)) _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( session.map { it?.currentScanSettings ?: initialSettings } .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), - savedSettingsUiState ?: defaultScanSettingsUIData, + savedSettingsUiStateWithCaps ?: defaultScanSettingsUIData, updateSettings, viewModelScope ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt index 8ca88fc2..44284492 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt @@ -7,6 +7,7 @@ import androidx.datastore.migrations.SharedPreferencesMigration import com.google.protobuf.BoolValue import com.google.protobuf.StringValue import com.google.protobuf.UInt32Value +import io.github.chrisimx.scanbridge.migrations.appsettingsstore.AppSettingsMigrationV0To1 import io.github.chrisimx.scanbridge.proto.LastRoute import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages @@ -32,7 +33,8 @@ val Context.appSettingsStore: DataStore by dataStore( chunkSizePdfExport = UInt32Value.of(sharedPrefs.getInt("chunk_size_pdf_export", 50)) rememberScanSettings = BoolValue.of(sharedPrefs.getBoolean("remember_scan_settings", true)) }.build() - } + }, + AppSettingsMigrationV0To1 ) } ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt new file mode 100644 index 00000000..d3291e4f --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt @@ -0,0 +1,79 @@ +package io.github.chrisimx.scanbridge.migrations.appsettingsstore + +import androidx.datastore.core.DataMigration +import com.google.protobuf.StringValue +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.anyscancompat.CommonAbstractionConversionResult +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.scanbridge.ScanSettingsJson +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 +import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings +import io.github.chrisimx.scanbridge.proto.copy +import io.github.chrisimx.scanbridge.proto.lastUsedScanSettingsUiStateOrNull +import timber.log.Timber + +// V0 to V1, change type of serialized ScanSettings and ScanSettingsEnterableData +object AppSettingsMigrationV0To1 : DataMigration { + override suspend fun shouldMigrate(currentData: ScanBridgeSettings): Boolean { + return currentData.appSettingsVersion == 0 + } + + override suspend fun migrate(currentData: ScanBridgeSettings): ScanBridgeSettings { + val json = ScanSettingsJson.json + val oldScanSettingsString = currentData.lastUsedScanSettings?.value + val oldScanSettingsParsed = oldScanSettingsString?.let { + json.decodeFromString(it) + } + + val oldEnterableData = currentData.lastUsedScanSettingsUiStateOrNull?.value + val oldEnterableDataParsed = oldEnterableData?.let { + json.decodeFromString(it) + } + + val newScanSettings = oldScanSettingsParsed?.let { + json.encodeToString(oldScanSettingsParsed.toCommonAbstraction()) + } + + val newEnterableData = oldEnterableDataParsed?.let { + val commonCapsResult = it.capabilities.toCommonAbstraction() + + val commonCaps = when (commonCapsResult) { + is CommonAbstractionConversionResult.Success -> { + commonCapsResult.value + } + + is CommonAbstractionConversionResult.Failure -> { + Timber.e("Could not convert enterable data in Migration of LastUsedScanSettings V0 to V1") + return@let null + } + } + + json.encodeToString( + ScanSettingsEnterableDataV1( + commonCaps, + it.paperFormats, + it.customMenuEnabled, + it.widthString, + it.heightString, + it.maximumSize + ) + ) + } + + + return currentData.copy { + appSettingsVersion = 1 + if (newScanSettings != null) { + lastUsedScanSettings = StringValue.of(newScanSettings) + } + if (newEnterableData != null) { + lastUsedScanSettingsUiState = StringValue.of(newEnterableData) + } + } + } + + override suspend fun cleanUp() { + return + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt index 606ce6b6..edcf7493 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt @@ -21,11 +21,11 @@ package io.github.chrisimx.scanbridge.stores import android.content.Context import com.google.protobuf.StringValue -import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.ScanSettingsJson import io.github.chrisimx.scanbridge.datastore.appSettingsStore import io.github.chrisimx.scanbridge.datastore.updateSettings -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.proto.lastUsedScanSettingsOrNull import io.github.chrisimx.scanbridge.proto.lastUsedScanSettingsUiStateOrNull import io.github.chrisimx.scanbridge.proto.rememberScanSettingsOrNull @@ -38,7 +38,7 @@ object DefaultScanSettingsStore { return appPreferences.rememberScanSettingsOrNull?.value ?: true } - suspend fun save(context: Context, scanSettings: ScanSettings, uiStateData: ScanSettingsEnterableData?) { + suspend fun save(context: Context, scanSettings: CommonScanSettings, uiStateData: ScanSettingsEnterableDataV1?) { if (!isRememberSettingsEnabled(context)) { Timber.d("Scan settings persistence is disabled, skipping save") return @@ -61,7 +61,7 @@ object DefaultScanSettingsStore { } } - suspend fun load(context: Context): Pair { + suspend fun load(context: Context): Pair { if (!isRememberSettingsEnabled(context)) { Timber.d("Scan settings persistence is disabled, returning null") return null to null @@ -78,9 +78,9 @@ object DefaultScanSettingsStore { try { val json = ScanSettingsJson.json - val lastUsedScanSettingsDecoded = json.decodeFromString(lastUsedScanSettings) + val lastUsedScanSettingsDecoded = json.decodeFromString(lastUsedScanSettings) val lastUsedScanSettingsUIStateDecoded = lastUsedScanSettingsUiState?.let { - json.decodeFromString(it) + json.decodeFromString(it) } Timber.d("Loaded default scan settings $lastUsedScanSettings, $lastUsedScanSettingsUIStateDecoded") return lastUsedScanSettingsDecoded to lastUsedScanSettingsUIStateDecoded diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 75b4846d..67b8e56f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -22,9 +22,13 @@ package io.github.chrisimx.scanbridge.util import android.content.Context import android.icu.text.DecimalFormat import app.cash.paraphrase.getString -import io.github.chrisimx.csa.CSAEnumOrRaw +import io.github.chrisimx.anyscan.AnyScanEnumOrRaw +import io.github.chrisimx.anyscan.ColorMode +import io.github.chrisimx.anyscan.CommonInputSourceType +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.Millimeters import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EsclColorMode import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.JobState @@ -49,23 +53,100 @@ fun String.toDoubleLocalized(): Double = DecimalFormat.getInstance().parse(this) fun Double.toStringLocalized(): String = DecimalFormat.getInstance().format(this) -fun InputSource.toReadableString(context: Context): String = when (this) { - InputSource.Platen -> context.getString(R.string.platen) - InputSource.Feeder -> context.getString(R.string.adf) - InputSource.Camera -> context.getString(R.string.camera) +fun UIInputSourceType.toReadableString(context: Context): String = when (this) { + UIInputSourceType.PLATEN -> + context.getString(R.string.platen) + UIInputSourceType.ADF -> context.getString(R.string.adf) } -fun EsclColorModeEnumOrRaw.localizedString(context: Context): String = when (this) { - is CSAEnumOrRaw.Known -> when (this.value) { - EsclColorMode.BlackAndWhite1 -> context.getString(R.string.black_and_white) - EsclColorMode.RGB24 -> context.getString(FormattedResources.color_scan("24")) - EsclColorMode.RGB48 -> context.getString(FormattedResources.color_scan("48")) - EsclColorMode.AutoColorDetection -> context.getString(R.string.auto_detect) - EsclColorMode.Grayscale8 -> context.getString(FormattedResources.grayscale("8")) - EsclColorMode.Grayscale16 -> context.getString(FormattedResources.grayscale("16")) +/** + * Returns the caller object if it is contained in the provided list; otherwise, returns the first + * element of the list or null if the list is empty. + * + * @param list The list to search for the caller object. + * @return The caller object if it is contained in the list, the first element of the list if not contained, + * or null if the list is empty. + */ +fun T.takeIfContainedElseFirstOrNull(list: List): T? { + return if (list.contains(this)) { + this + } else { + list.firstOrNull() + } +} + +fun CommonScanSettings.coerceIn(capabilities: CommonScannerCapabilities): CommonScanSettings { + val inputSourceType = this.inputSource ?: CommonInputSourceType.PLATEN + val inputSourceCaps = capabilities.getInputSourceCaps(inputSourceType) + ?: capabilities.inputSources.firstOrNull() + ?: throw IllegalArgumentException("No input sources found in capabilities.") + + // Coerce resolution + val coercedResolution = this.resolution?.let { res -> + val availableResolutions = inputSourceCaps.supportedResolutions + if (availableResolutions.contains(res)) { + res + } else { + // Find closest resolution + availableResolutions.minByOrNull { + kotlin.math.abs(it.widthDPI.toInt() - res.widthDPI.toInt()) + + kotlin.math.abs(it.heightDPI.toInt() - res.heightDPI.toInt()) + } ?: res + } + } + + // Coerce color mode + val coercedColorMode = this.colorMode?.let { mode -> + if (inputSourceCaps.supportedColorModes.contains(mode)) { + mode + } else { + inputSourceCaps.supportedColorModes.firstOrNull() + } + } + + // Coerce scan area + val coercedScanArea = this.scanArea?.let { area -> + val minWidth = inputSourceCaps.minSize.width.toMillimeters().value + val maxWidth = inputSourceCaps.maxSize.width.toMillimeters().value + val minHeight = inputSourceCaps.minSize.height.toMillimeters().value + val maxHeight = inputSourceCaps.maxSize.height.toMillimeters().value + + area.copy( + width = Millimeters(area.width.toMillimeters().value.coerceIn(minWidth, maxWidth)), + height = Millimeters(area.height.toMillimeters().value.coerceIn(minHeight, maxHeight)) + ) + } + + // Coerce format + val coercedFormat = this.format?.takeIfContainedElseFirstOrNull( + inputSourceCaps.supportedFileFormats + ) + + // Coerce intent + val coercedIntent = this.scanIntent?.takeIfContainedElseFirstOrNull( + inputSourceCaps.supportedIntents + ) + + return this.copy( + inputSource = inputSourceType, + resolution = coercedResolution, + colorMode = coercedColorMode, + scanArea = coercedScanArea, + format = coercedFormat, + scanIntent = coercedIntent + ) +} +fun AnyScanEnumOrRaw.localizedString(context: Context): String = when (this) { + is AnyScanEnumOrRaw.Known -> when (this.value) { + ColorMode.BlackAndWhite1 -> context.getString(R.string.black_and_white) + ColorMode.RGB24 -> context.getString(FormattedResources.color_scan("24")) + ColorMode.RGB48 -> context.getString(FormattedResources.color_scan("48")) + ColorMode.AutoColorDetection -> context.getString(R.string.auto_detect) + ColorMode.Grayscale8 -> context.getString(FormattedResources.grayscale("8")) + ColorMode.Grayscale16 -> context.getString(FormattedResources.grayscale("16")) } - is CSAEnumOrRaw.Unknown -> this.asString() + is AnyScanEnumOrRaw.Unknown -> this.asString() } fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteResolution { @@ -77,7 +158,7 @@ fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteReso return maxResolution } -fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): EsclColorModeEnumOrRaw? { +fun CommonScannerCapabilities.getBestColorMode(inputSource: InputSource): EsclColorModeEnumOrRaw? { val inputCaps = this.getInputSourceCaps(inputSource) val chosenColorMode = inputCaps.settingProfiles.elementAtOrNull(0)?.colorModes?.maxByOrNull { when (it) { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIInputSourceType.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIInputSourceType.kt new file mode 100644 index 00000000..54b03a4a --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIInputSourceType.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.chrisimx.anyscan.CommonInputSourceType + +enum class UIInputSourceType { + PLATEN, + ADF +} + +fun CommonInputSourceType.toUIInputSourceType(): UIInputSourceType = when (this) { + CommonInputSourceType.PLATEN -> UIInputSourceType.PLATEN + CommonInputSourceType.ADF_SIMPLEX -> UIInputSourceType.ADF + CommonInputSourceType.ADF_DUPLEX -> UIInputSourceType.ADF +} diff --git a/androidApp/src/main/proto/app_settings.proto b/androidApp/src/main/proto/app_settings.proto index fa5eee47..5c956483 100644 --- a/androidApp/src/main/proto/app_settings.proto +++ b/androidApp/src/main/proto/app_settings.proto @@ -6,6 +6,7 @@ option java_multiple_files = true; import "google/protobuf/wrappers.proto"; message ScanBridgeSettings { + uint32 app_settings_version = 10; bool auto_cleanup = 1; bool write_debug = 2; bool disable_cert_checks = 3; diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 15542788..0124f3c0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -57,7 +57,7 @@ kotlin { api(libs.ktor.logging) api(libs.esclkt) api(libs.wsdkt) - api(libs.commonScannerAbstractions) + api(libs.anyscanCore) api(libs.nonThrowingKtor) // Room deps diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt index 344a498a..86747942 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt @@ -1,8 +1,8 @@ package io.github.chrisimx.scanbridge -import io.github.chrisimx.csa.LengthUnit -import io.github.chrisimx.csa.Millimeters -import io.github.chrisimx.csa.millimeters +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.Millimeters +import io.github.chrisimx.anyscan.millimeters import kotlinx.serialization.Serializable @Serializable diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt index e91a5ea2..3c46f269 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt @@ -1,10 +1,10 @@ package io.github.chrisimx.scanbridge -import io.github.chrisimx.csa.Inches -import io.github.chrisimx.csa.LengthUnit -import io.github.chrisimx.csa.Millimeters -import io.github.chrisimx.csa.Points -import io.github.chrisimx.csa.ThreeHundredthsOfInch +import io.github.chrisimx.anyscan.Inches +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.Millimeters +import io.github.chrisimx.anyscan.Points +import io.github.chrisimx.anyscan.ThreeHundredthsOfInch import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt index 4c56adab..6cffd0c2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt @@ -16,8 +16,10 @@ import io.github.chrisimx.scanbridge.db.entities.LastRoute 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.db.typeconverters.CommonScanSettingsTypeConverter import io.github.chrisimx.scanbridge.db.typeconverters.ScanSettingsTypeConverter -import io.github.chrisimx.scanbridge.db.typeconverters.ScanSettingsUiDataTypeConverter +import io.github.chrisimx.scanbridge.db.typeconverters.ScanSettingsUiDataTypeConverterV0 +import io.github.chrisimx.scanbridge.db.typeconverters.ScanSettingsUiDataTypeConverterV1 import io.github.chrisimx.scanbridge.db.typeconverters.UrlTypeConverter import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter @@ -49,7 +51,10 @@ import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter UuidTypeConverter::class, UrlTypeConverter::class, ScanSettingsTypeConverter::class, - ScanSettingsUiDataTypeConverter::class + CommonScanSettingsTypeConverter::class, + ScanSettingsUiDataTypeConverterV0::class, + ScanSettingsUiDataTypeConverterV1::class, + CommonScanSettingsTypeConverter::class, ) abstract class ScanBridgeDb : RoomDatabase() { abstract fun customScannerDao(): CustomScannerDao diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/SessionDao.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/SessionDao.kt index 89a62a09..684b01dd 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/SessionDao.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/SessionDao.kt @@ -6,7 +6,7 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Update import io.github.chrisimx.scanbridge.db.entities.Session -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import kotlin.uuid.Uuid import kotlinx.coroutines.flow.Flow @@ -34,7 +34,7 @@ interface SessionDao { suspend fun updateCurrentPage(sessionId: Uuid, pageIdx: Int) @Query("UPDATE sessions SET currentSettingsUIData = :uiData WHERE sessionId = :sessionId") - suspend fun updateScanSettingsUiData(sessionId: Uuid, uiData: ScanSettingsEnterableData?) + suspend fun updateScanSettingsUiData(sessionId: Uuid, uiData: ScanSettingsEnterableDataV1?) @Delete suspend fun delete(session: Session) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt index 24210d04..f67b3b47 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt @@ -5,7 +5,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.model.ScanRelativeRotation import kotlin.uuid.Uuid @@ -32,7 +32,7 @@ data class ScannedPage( val scanId: Uuid, val ownerSessionId: Uuid, val filePath: String, - val originalScanSettings: ScanSettings, + val originalScanSettings: CommonScanSettings, val rotation: ScanRelativeRotation = ScanRelativeRotation.Original, val orderIndex: Int, @ColumnInfo(defaultValue = "null") diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/Session.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/Session.kt index 76d4024c..ac658f4d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/Session.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/Session.kt @@ -3,16 +3,16 @@ package io.github.chrisimx.scanbridge.db.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import kotlin.uuid.Uuid @Entity(tableName = "sessions") data class Session( @PrimaryKey val sessionId: Uuid, - val currentScanSettings: ScanSettings?, + val currentScanSettings: CommonScanSettings?, @ColumnInfo(defaultValue = "null") - val currentSettingsUIData: ScanSettingsEnterableData?, + val currentSettingsUIData: ScanSettingsEnterableDataV1?, val currentPage: Int = 0 ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt new file mode 100644 index 00000000..36a7ae1c --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.db.typeconverters + +import io.github.chrisimx.anyscan.CommonScanSettings + +class CommonScanSettingsTypeConverter : JsonSerializationTypeConverter( + CommonScanSettings.serializer() +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt new file mode 100644 index 00000000..1ab2a26a --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt @@ -0,0 +1,24 @@ +package io.github.chrisimx.scanbridge.db.typeconverters + +import androidx.room.TypeConverter +import io.github.chrisimx.scanbridge.ScanSettingsJson +import kotlin.reflect.KClass +import kotlinx.serialization.KSerializer + +abstract class JsonSerializationTypeConverter( + private val serializer: KSerializer +) { + @TypeConverter + fun fromSerializedString(serialized: String): T? = if (serialized == "null") { + null + } else { + ScanSettingsJson.json.decodeFromString(serializer, serialized) + } + + @TypeConverter + fun toSerializedString(scanSettings: T?): String = if (scanSettings == null) { + "null" + } else { + ScanSettingsJson.json.encodeToString(serializer, scanSettings) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt index 761d2840..b7511bbf 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt @@ -4,19 +4,4 @@ import androidx.room.TypeConverter import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.scanbridge.ScanSettingsJson -class ScanSettingsTypeConverter { - - @TypeConverter - fun fromScanSettingsString(scanSettings: String): ScanSettings? = if (scanSettings == "null") { - null - } else { - ScanSettingsJson.json.decodeFromString(scanSettings) - } - - @TypeConverter - fun toScanSettingsString(scanSettings: ScanSettings?): String = if (scanSettings == null) { - "null" - } else { - ScanSettingsJson.json.encodeToString(scanSettings) - } -} +class ScanSettingsTypeConverter : JsonSerializationTypeConverter(ScanSettings.serializer()) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt index 0046b2fc..1e152325 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt @@ -1,22 +1,12 @@ package io.github.chrisimx.scanbridge.db.typeconverters -import androidx.room.TypeConverter -import io.github.chrisimx.scanbridge.ScanSettingsJson -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 -class ScanSettingsUiDataTypeConverter { +class ScanSettingsUiDataTypeConverterV0 : JsonSerializationTypeConverter( + ScanSettingsEnterableDataV0.serializer() +) - @TypeConverter - fun fromScanSettingsString(scanSettings: String): ScanSettingsEnterableData? = if (scanSettings == "null") { - null - } else { - ScanSettingsJson.json.decodeFromString(scanSettings) - } - - @TypeConverter - fun toScanSettingsString(scanSettings: ScanSettingsEnterableData?): String = if (scanSettings == null) { - "null" - } else { - ScanSettingsJson.json.encodeToString(scanSettings) - } -} +class ScanSettingsUiDataTypeConverterV1 : JsonSerializationTypeConverter( + ScanSettingsEnterableDataV1.serializer() +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index 44b2ce08..42f3a28a 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -1,10 +1,14 @@ package io.github.chrisimx.scanbridge.escl +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.esclkt.ESCLRequestClient import io.github.chrisimx.safektor.ESCLHttpCallResult import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.esclkt.ScanJob -import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.anyscancompat.CommonAbstractionConversionResult +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.esclkt.anyscancompat.toESCLScanSettings import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.ScanningError @@ -78,17 +82,28 @@ class EsclScanningProtocol( esclRequestClient.getScannerCapabilities() } - return when (scannerCapsResult) { - is ESCLRequestClient.ScannerCapabilitiesResult.InternalBug -> ScannerCapabilitiesResult.InternalBug(scannerCapsResult.exception) - is ESCLRequestClient.ScannerCapabilitiesResult.RequestFailure -> when (val error = scannerCapsResult.error) { + when (scannerCapsResult) { + is ESCLRequestClient.ScannerCapabilitiesResult.InternalBug -> return ScannerCapabilitiesResult.InternalBug(scannerCapsResult.exception) + is ESCLRequestClient.ScannerCapabilitiesResult.RequestFailure -> return when (val error = scannerCapsResult.error) { is ESCLHttpCallResult.Error.UntrustedCertificate -> ScannerCapabilitiesResult.UntrustedCertificate(error.cause) else -> ScannerCapabilitiesResult.Failure(error) } - is ESCLRequestClient.ScannerCapabilitiesResult.ScannerCapabilitiesMalformed -> ScannerCapabilitiesResult.ScannerCapsFormatInvalid( + is ESCLRequestClient.ScannerCapabilitiesResult.ScannerCapabilitiesMalformed -> return ScannerCapabilitiesResult.ScannerCapsFormatInvalid( scannerCapsResult.exception, scannerCapsResult.content ) - is ESCLRequestClient.ScannerCapabilitiesResult.Success -> ScannerCapabilitiesResult.Success(scannerCapsResult.scannerCapabilities) + is ESCLRequestClient.ScannerCapabilitiesResult.Success -> {} + } + + val esclScanCaps = scannerCapsResult.scannerCapabilities + return when (val conversionResult = esclScanCaps.toCommonAbstraction()) { + is CommonAbstractionConversionResult.Failure -> ScannerCapabilitiesResult.ScannerCapsFormatInvalid( + Exception("Could not convert eSCL scanner capabilities to CommonAbstraction"), + scannerCapsResult.scannerCapabilities.toString() + ) + is CommonAbstractionConversionResult.Success -> ScannerCapabilitiesResult.Success( + conversionResult.value + ) } } @@ -150,7 +165,7 @@ class EsclScanningProtocol( override fun executeScanJob( handle: ScannerHandle, settings: ScannerConnectionSettings, - jobScanSettings: ScanSettings, + jobScanSettings: CommonScanSettings, cancelled: StateFlow ): Flow = flow { val scannerUrlHandle = @@ -180,9 +195,10 @@ class EsclScanningProtocol( if (abortIfCancelling()) return@flow - _logger.debug { "Creating scan job. eSCLKt scan settings: $jobScanSettings" } + val esclScanSettings = jobScanSettings.toESCLScanSettings() + _logger.debug { "Creating scan job. Common scan settings: $jobScanSettings eSCL scan settings: $esclScanSettings" } - val job = esclRequestClient.createJob(jobScanSettings) + val job = esclRequestClient.createJob(esclScanSettings) _logger.debug { "Creation request done. Result: $job" } if (job !is ESCLRequestClient.ScannerCreateJobResult.Success) { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt index e022c045..e73cfb66 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt @@ -1,13 +1,13 @@ package io.github.chrisimx.scanbridge.model -import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import kotlin.uuid.Uuid data class ScanJob( val jobID: Uuid, val ownerSessionId: Uuid, - val scanSettings: ScanSettings, + val scanSettings: CommonScanSettings, val scannerHandle: ScannerHandle, val connectionSettings: ScannerConnectionSettings ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt index 71806903..115eb73f 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt @@ -1,12 +1,13 @@ package io.github.chrisimx.scanbridge.model +import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.esclkt.ScannerCapabilities import io.github.chrisimx.scanbridge.PaperFormat import io.github.chrisimx.scanbridge.loadDefaultFormats import kotlinx.serialization.Serializable @Serializable -data class ScanSettingsEnterableData( +data class ScanSettingsEnterableDataV0( val capabilities: ScannerCapabilities, val paperFormats: List = loadDefaultFormats(), val customMenuEnabled: Boolean = false, @@ -14,3 +15,14 @@ data class ScanSettingsEnterableData( val heightString: String = "", val maximumSize: Boolean = true ) + + +@Serializable +data class ScanSettingsEnterableDataV1( + val capabilities: CommonScannerCapabilities, + val paperFormats: List = loadDefaultFormats(), + val customMenuEnabled: Boolean = false, + val widthString: String = "", + val heightString: String = "", + val maximumSize: Boolean = true +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt index 1a7b0b7f..5eaf28d0 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -1,8 +1,7 @@ package io.github.chrisimx.scanbridge.ports -import io.github.chrisimx.esclkt.ESCLRequestClient -import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.ScanningError @@ -18,7 +17,7 @@ data class ScannerConnectionSettings( ) sealed class ScannerCapabilitiesResult { - data class Success(val scannerCapabilities: ScannerCapabilities) : ScannerCapabilitiesResult() + data class Success(val scannerCapabilities: CommonScannerCapabilities) : ScannerCapabilitiesResult() data class ScannerCapsFormatInvalid(val error: Exception, val scannerCapsContent: String?) : ScannerCapabilitiesResult() data class UntrustedCertificate(val error: String?) : ScannerCapabilitiesResult() data class InvalidScannerHandle(val scannerHandle: ScannerHandle) : ScannerCapabilitiesResult() @@ -77,7 +76,7 @@ interface ScanningProtocol { */ fun executeScanJob(handle: ScannerHandle, settings: ScannerConnectionSettings, - jobScanSettings: ScanSettings, + jobScanSettings: CommonScanSettings, cancelled: StateFlow ): Flow } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d5d8a97..ab87c417 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -98,7 +98,7 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" coil-fetcher-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } esclkt = { module = "io.github.chrisimx:esclkt", version.ref = "anyscankt" } wsdkt = { module = "io.github.chrisimx:wsdkt", version.ref = "anyscankt" } -commonScannerAbstractions = { module = "io.github.chrisimx:common-scanner-abstractions", version.ref = "anyscankt" } +anyscanCore = { module = "io.github.chrisimx:anyscankt-core", version.ref = "anyscankt" } nonThrowingKtor = { module = "io.github.chrisimx:nonthrowingktor", version.ref = "anyscankt" } itext7-core = { module = "com.itextpdf:itext-core", version.ref = "itextCore" } junit = { group = "junit", name = "junit", version.ref = "junit" } From 322a922a6b49e2d40987d944984c60e3af317d03 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 11 Jul 2026 23:08:05 +0200 Subject: [PATCH 11/38] Dynamic scan settings --- .../chrisimx/scanbridge/ScanSettings.kt | 162 ++------ .../chrisimx/scanbridge/ScannerBrowser.kt | 10 +- .../ui/ScanSettingsComposableStateHolder.kt | 363 ++---------------- .../data/ui/ScanningScreenViewModel.kt | 55 ++- .../AppSettingsMigrationV0To1.kt | 5 +- .../stores/LegacyCustomScannerStore.kt | 3 +- .../scanbridge/stores/LegacySessionsStore.kt | 5 +- .../scanbridge/util/ESCLKtExtensions.kt | 97 ----- .../5.json | 13 +- .../chrisimx/scanbridge/ScanSettingsJson.kt | 8 + .../scanbridge/db/entities/CustomScanner.kt | 5 +- 11 files changed, 132 insertions(+), 594 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index d5ba8632..87e84880 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -53,7 +53,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import io.github.chrisimx.anyscan.Choice +import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.DiscreteResolution +import io.github.chrisimx.anyscan.ScanSettingParam +import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.equalsLength import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit @@ -76,20 +80,9 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp val context = LocalContext.current val vmData by scanSettingsStateHolder.uiState.collectAsState() - val currentResolution by scanSettingsStateHolder.currentResolution.collectAsState() - val currentScanRegion by scanSettingsStateHolder.currentScanRegion.collectAsState() - - val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexCurrentlyAvailable.collectAsState() - - val currentColorMode by scanSettingsStateHolder.currentColorMode.collectAsState() + val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexCurrentlyActive.collectAsState() val inputSourceOptions by scanSettingsStateHolder.inputSourceOptions.collectAsState() - val supportedResolutions by scanSettingsStateHolder.supportedScanResolutions.collectAsState() - val intentOptions by scanSettingsStateHolder.intentOptions.collectAsState() - val supportedColorModes by scanSettingsStateHolder.supportedColorModes.collectAsState() - - val widthValidationResult by scanSettingsStateHolder.widthValidationResult.collectAsState(NumberValidationResult.NotANumber) - val heightValidationResult by scanSettingsStateHolder.heightValidationResult.collectAsState(NumberValidationResult.NotANumber) val userUnitEnum by scanSettingsStateHolder.lengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) @@ -98,13 +91,13 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp ScanSettingsLengthUnit.MILLIMETER -> stringResource(R.string.millimeter_unit_abbreviation) } + val availableParameters by scanSettingsStateHolder.availableParameters.collectAsState() + val scanSettings by scanSettingsStateHolder.scanSettings.collectAsState() val selectedInputSource by scanSettingsStateHolder.selectedInputSource.collectAsState() val duplexUsed by scanSettingsStateHolder.duplexUsed.collectAsState() - val selectedIntent by scanSettingsStateHolder.currentIntent.collectAsState() - val scrollState = rememberScrollState() Column( @@ -144,132 +137,35 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp var fitsRowVersion by remember { mutableStateOf(false) } - SizeBasedConditionalView( - modifier = Modifier, - largeView = { - ResolutionSettingButtonRowVersion(supportedResolutions, currentResolution) { x, y -> - scanSettingsStateHolder.setResolution(x, y) - } - }, - smallView = { - ResolutionSettingCardVersion(supportedResolutions, currentResolution) { x, y -> - scanSettingsStateHolder.setResolution(x, y) - } - }, - onViewChosen = { fitsRowVersion = it } - ) - - if (intentOptions.isNotEmpty()) { - SelectionCardWithDefault( - stringResource(R.string.intent), - intentOptions, - { - scanSettingsStateHolder.setIntent(it) - }, - { this.asString() }, - selectedIntent, - fitsRowVersion - ) - } - + for (parameterPair in availableParameters) { + val (scannerConcept, parameter) = parameterPair - SelectionCardWithDefault( - stringResource(R.string.color_mode), - supportedColorModes, - { - scanSettingsStateHolder.setColorMode(it) - }, - { this.localizedString(context) }, - currentColorMode - ) - - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 15.dp, bottom = 15.dp) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - stringResource(R.string.scan_region), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) + val scannerConceptLocalizedName = when (scannerConcept) { + ScannerConcept.ColorMode -> R.string.color_mode + ScannerConcept.ScanIntent -> R.string.intent + ScannerConcept.ScanRegion -> R.string.scan_region + ScannerConcept.ScanResolution -> R.string.resolution_dpi + } - FlowRow( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - vmData.paperFormats.forEach { paperFormat -> - InputChip( - onClick = { - scanSettingsStateHolder.setCustomMenuEnabled(false) - scanSettingsStateHolder.setRegionDimension( - paperFormat.width, - paperFormat.height - ) - Timber.tag(TAG).d("New region state: ${scanSettings.scanArea}") - }, - label = { Text(paperFormat.name) }, - selected = !vmData.customMenuEnabled && !vmData.maximumSize && - currentScanRegion?.width?.equalsLength(paperFormat.width) == true && - currentScanRegion?.height?.equalsLength(paperFormat.height) == true - ) - } - InputChip( - onClick = { - scanSettingsStateHolder.setCustomMenuEnabled(false) - scanSettingsStateHolder.selectMaxRegion() + when (parameter) { + is ScanSettingParam.ScanSettingBoolParam -> TODO() + is ScanSettingParam.ScanSettingChoiceParam<*> -> { + SelectionCardWithDefault( + stringResource(scannerConceptLocalizedName), + parameter.availableChoices, + { selectedChoice -> + scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice?.value) }, - label = { Text(stringResource(R.string.maximum_size)) }, - selected = - vmData.maximumSize && !vmData.customMenuEnabled - ) - InputChip( - selected = vmData.customMenuEnabled, - onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, - label = { Text(stringResource(R.string.custom)) } + { this.value.toString() }, + scanSettings.setting[parameter.concept] as Choice? ) } - AnimatedVisibility(vmData.customMenuEnabled) { - Row(horizontalArrangement = Arrangement.SpaceEvenly) { - ValidatedDimensionsTextEdit( - vmData.widthString, - context, - modifier = Modifier - .weight(1f) - .padding(end = 10.dp), - stringResource(R.string.width_in_unit, userUnitString), - { newText: String -> - scanSettingsStateHolder.setCustomWidthTextFieldContent( - newText - ) - }, - widthValidationResult - ) - ValidatedDimensionsTextEdit( - vmData.heightString, - context, - modifier = Modifier - .weight(1f) - .padding(start = 10.dp), - stringResource(R.string.height_in_unit, userUnitString), - { scanSettingsStateHolder.setCustomHeightTextFieldContent(it) }, - heightValidationResult - ) - } - } + is ScanSettingParam.ScanSettingDoubleParam -> TODO() + is ScanSettingParam.ScanSettingFloatParam -> TODO() + is ScanSettingParam.ScanSettingIntParam -> TODO() + is ScanSettingParam.ScanSettingRegionParam -> TODO() } } - Button( - modifier = Modifier.padding(horizontal = 15.dp).testTag("copyesclkt"), - onClick = { scanSettingsStateHolder.copySettingsToClipboard() } - ) { - Text( - stringResource(R.string.copy_current_scanner_options_in_esclkt_format), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center - ) - } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index 8c185721..d8236825 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -105,10 +105,9 @@ fun ScannerList( customScanners.forEach { customScanner -> item { - // TODO: Store protocol in database FoundScannerItem( customScanner.url.toString(), - "eSCL", + customScanner.protocolIdentifier, customScanner.name, null, navController, @@ -196,8 +195,11 @@ fun ScannerBrowser( is EditedCustomScanner.EditingOld -> currentlyEditedScanner.scanner.uuid EditedCustomScanner.New -> Uuid.random() } + val protocolIdentifier = "eSCL" if (save) { - scannerDiscoveryScreenViewModel.addScanner(CustomScanner(uuid, name, Url(url))) + scannerDiscoveryScreenViewModel.addScanner( + CustomScanner(uuid, name, Url(url), protocolIdentifier) + ) } setEditedCustomDialog(null) if (navigate) { @@ -205,7 +207,7 @@ fun ScannerBrowser( ScannerRoute( name, url, - "eSCL", // TODO: Store protocol in database and make this choosable + protocolIdentifier, sessionID.toString() ) ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 15741466..04a8e507 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -20,32 +20,18 @@ package io.github.chrisimx.scanbridge.data.ui import android.app.Application -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context.CLIPBOARD_SERVICE -import io.github.chrisimx.anyscan.Area -import io.github.chrisimx.anyscan.AnyScanEnumOrRaw -import io.github.chrisimx.anyscan.ColorMode import io.github.chrisimx.anyscan.CommonInputSourceCaps import io.github.chrisimx.anyscan.CommonInputSourceType import io.github.chrisimx.anyscan.CommonScanSettings -import io.github.chrisimx.anyscan.DiscreteResolution -import io.github.chrisimx.anyscan.FileFormat +import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.LengthUnit -import io.github.chrisimx.anyscan.ScanIntent -import io.github.chrisimx.anyscan.ThreeHundredthsOfInch -import io.github.chrisimx.anyscan.asEnumOrRaw -import io.github.chrisimx.anyscan.inches -import io.github.chrisimx.anyscan.millimeters -import io.github.chrisimx.anyscan.threeHundredthsOfInch -import io.github.chrisimx.scanbridge.R +import io.github.chrisimx.anyscan.ScanSettingParam +import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.scanbridge.model.Locale -import io.github.chrisimx.scanbridge.model.NumberValidationResult import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.util.UIInputSourceType import io.github.chrisimx.scanbridge.util.derived -import io.github.chrisimx.scanbridge.util.toDoubleLocalized import io.github.chrisimx.scanbridge.util.toUIInputSourceType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -55,13 +41,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.zip import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam import timber.log.Timber @@ -77,7 +60,7 @@ class ScanSettingsComposableStateHolder( @InjectedParam private val initialScanSettingsData: ScanSettingsEnterableDataV1, @InjectedParam - private val updateSettings: suspend (CommonScanSettings.() -> CommonScanSettings) -> Unit, + private val updateSettings: suspend (CommonScanSettingsEditor.() -> Unit) -> Unit, @InjectedParam private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, @@ -107,7 +90,7 @@ class ScanSettingsComposableStateHolder( } != null } - val duplexCurrentlyAvailable: StateFlow = combine(duplexAdfSupported, scanSettings) { duplexSupport, scanSettings -> + val duplexCurrentlyActive: StateFlow = combine(duplexAdfSupported, scanSettings) { duplexSupport, scanSettings -> duplexSupport && scanSettings.inputSource == CommonInputSourceType.ADF_DUPLEX }.stateIn(coroutineScope, SharingStarted.Lazily, false) @@ -121,97 +104,18 @@ class ScanSettingsComposableStateHolder( uiState.value.capabilities.inputSources.first() ) - val intentOptions = selectedInputSourceCaps.derived(coroutineScope) { - it.supportedIntents - } - - val supportedScanResolutions = selectedInputSourceCaps.derived(coroutineScope) { - it.supportedResolutions - } - - val supportedColorModes = selectedInputSourceCaps.derived(coroutineScope) { - it.supportedColorModes - } - - val currentColorMode = scanSettings.derived(coroutineScope) { - it.colorMode - } - - val currentResolution: StateFlow = scanSettings.derived(coroutineScope) { - return@derived it.resolution + val availableParameters = selectedInputSourceCaps.derived(coroutineScope) { + it.furtherOptions } val lengthUnit = localeProvider.locale.derived(coroutineScope) { unitByLocale(it) } - val currentWidthText = _uiState.derived(coroutineScope) { - it.widthString - } - - val currentHeightText = _uiState.derived(coroutineScope) { - it.heightString - } - - val currentScanRegion = scanSettings.derived(coroutineScope) { - it.scanArea - } - - val currentIntent = scanSettings.derived(coroutineScope) { - it.scanIntent - } - - val heightValidationResult = combine(currentHeightText, lengthUnit, selectedInputSourceCaps) - { heightText, unit, inputSourceCaps -> - val maxHeight = inputSourceCaps.maxSize.height - val minHeight = inputSourceCaps.minSize.height - return@combine validateCustomLengthInput( - lengthText = heightText, - unit = unit, - max = maxHeight.toThreeHundredthsOfInch(), - min = minHeight.toThreeHundredthsOfInch()) - }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) - - val widthValidationResult = combine(currentWidthText, lengthUnit, selectedInputSourceCaps) - { widthText, unit, inputSourceCaps -> - val maxWidth = inputSourceCaps.maxSize.width - val minWidth = inputSourceCaps.minSize.width - return@combine validateCustomLengthInput( - lengthText = widthText, - unit = unit, - max = maxWidth.toThreeHundredthsOfInch(), - min = minWidth.toThreeHundredthsOfInch()) - }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) - - private fun validateCustomLengthInput( - lengthText: String, - unit: ScanSettingsLengthUnit, - max: ThreeHundredthsOfInch, - min: ThreeHundredthsOfInch - ): NumberValidationResult { - val parsedLength = runCatching { - lengthText.toDoubleLocalized() - }.getOrNull() - - if (parsedLength == null) { - return NumberValidationResult.NotANumber - } - - val lengthInUnit = when (unit) { - ScanSettingsLengthUnit.INCH -> parsedLength.inches() - ScanSettingsLengthUnit.MILLIMETER -> parsedLength.millimeters() - } - - val inputLengthInT300 = lengthInUnit.toThreeHundredthsOfInch().value - - if (inputLengthInT300 in min.value..max.value) { - return NumberValidationResult.Success(inputLengthInT300.toDouble()) - } else { - val maxInUserUnit = toUserUnit(unit, max) - val minInUserUnit = toUserUnit(unit, min) - - return NumberValidationResult.OutOfRange(minInUserUnit, maxInUserUnit) - } + private fun unitByLocale(locale: Locale): ScanSettingsLengthUnit = if (locale.country in setOf("US", "LR", "MM")) { + ScanSettingsLengthUnit.INCH + } else { + ScanSettingsLengthUnit.MILLIMETER } private fun toUserUnit(unit: ScanSettingsLengthUnit, length: LengthUnit): Double = when (unit) { @@ -220,9 +124,6 @@ class ScanSettingsComposableStateHolder( } init { - observeHeightValidation() - observeWidthValidation() - _uiState .map { it.maximumSize } .distinctUntilChanged() @@ -230,88 +131,21 @@ class ScanSettingsComposableStateHolder( .filter { it.first } .onEach { (maxSize, inputSourceCaps) -> Timber.d("Maximum size flag set to $maxSize: This means we should set scanRegion to maximum") - updateSettings { - copy( - scanArea = inputSourceCaps.maxSize - ) - } - }.launchIn(coroutineScope) - } - - private fun observeWidthValidation() { - widthValidationResult - .filterIsInstance() - .distinctUntilChanged() - .zip(selectedInputSourceCaps) { widthValidationResult, inputSourceCaps -> Pair(widthValidationResult, inputSourceCaps) } - .onEach { (widthValidationResult, inputSourceCaps) -> - updateSettings { - val currentScanRegion = scanArea - Timber.d("Width validation success result received: $widthValidationResult") - - if (currentScanRegion == null) { - Timber.d("Width validation success and current scanRegion null, replacing!") - return@updateSettings copy( - scanArea = Area( - height = inputSourceCaps.maxSize.height, - width = widthValidationResult.value.threeHundredthsOfInch() - ) - ) - } else { - Timber.d("Width validation success and current scanRegion not null, reusing!") - val currentHeight = currentScanRegion.height - return@updateSettings copy( - scanArea = Area( - width = widthValidationResult.value.threeHundredthsOfInch(), - height = currentHeight - ) - ) - } - } - } - .launchIn(coroutineScope) - } - - private fun observeHeightValidation() { - heightValidationResult - .onEach { - Timber.d("Height Validation result $it") - } - .filterIsInstance() - .distinctUntilChanged() - .zip(selectedInputSourceCaps) { heightValidationResult, inputSourceCaps -> Pair(heightValidationResult, inputSourceCaps) } - .onEach { (heightValidationResult, inputSource) -> - Timber.d("Height validation success result received: $heightValidationResult") - updateSettings { - val currentScanRegion = scanArea - - if (currentScanRegion == null) { - Timber.d("Height validation success and current scanRegion null, replacing!") - return@updateSettings copy( - scanArea = Area( - width = inputSource.maxSize.width, - height = heightValidationResult.value.threeHundredthsOfInch() - ) - ) - } else { - Timber.d("Height validation success and current scanRegion not null, reusing!") - val currentWidth = currentScanRegion.width - return@updateSettings copy( - scanArea = Area( - width = currentWidth, - height = heightValidationResult.value.threeHundredthsOfInch() - ) - ) + val regionParam = inputSourceCaps + .furtherOptions[ScannerConcept.ScanRegion] as ScanSettingParam.ScanSettingRegionParam? + regionParam?.maxArea?.let { maxArea -> + updateSettings { + set(ScannerConcept.ScanRegion, maxArea) } } - } - .launchIn(coroutineScope) + }.launchIn(coroutineScope) } fun setDuplex(duplex: Boolean) { - val isDuplexCurrentlyAvailable = duplexCurrentlyAvailable.value + val duplexCurrentlyActive = duplexCurrentlyActive.value - if (duplex && !isDuplexCurrentlyAvailable) { - Timber.d("Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyAvailable") + if (duplex && !duplexCurrentlyActive) { + Timber.d("Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyActive") return } @@ -323,24 +157,7 @@ class ScanSettingsComposableStateHolder( coroutineScope.launch { updateSettings { - copy(inputSource = newInputSource) - } - } - } - - fun setColorMode(colorMode: AnyScanEnumOrRaw?) { - coroutineScope.launch { - if (colorMode is AnyScanEnumOrRaw.Known && colorMode.value == ColorMode.BlackAndWhite1) { - Timber.d("Selecting b&w. Switching to PDF format") - updateSettings { - copy(colorMode = colorMode, - format = FileFormat.PDF.asEnumOrRaw()) - } - } else { - Timber.d("Selecting a color mode not b&w. Using jpeg again") - updateSettings { - copy(colorMode = colorMode, format = FileFormat.JPEG.asEnumOrRaw()) - } + setInputSource(inputSource = newInputSource) } } } @@ -355,143 +172,21 @@ class ScanSettingsComposableStateHolder( coroutineScope.launch { updateSettings { - //val currentScanSettings = scanSettings.value - val uiState = uiState.value - val inputSourceCaps = uiState.capabilities.getInputSourceCaps(newInputSource)!! - - val supportedResolutions = inputSourceCaps.supportedResolutions - - val xRes = this.resolution?.widthDPI - val yRes = this.resolution?.heightDPI - - Timber.d("Input source being set. Current Resolution is: $xRes x $yRes") - - val invalidResolutionSetting = xRes != null && yRes != null && - !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) - - val replacementResolution = if (invalidResolutionSetting) { - val highestScanResolution = inputSourceCaps.supportedResolutions - .maxBy { it.widthDPI + it.heightDPI } - - DiscreteResolution(widthDPI = highestScanResolution.widthDPI, heightDPI = highestScanResolution.heightDPI) - } else { - if (xRes != null && yRes != null) { - DiscreteResolution(widthDPI = xRes, heightDPI = yRes) - } else { - null - } - } - - val intentSupported = this.scanIntent?.let { inputSourceCaps.supportedIntents.contains(it) } ?: true - - val replacementIntent = if (intentSupported) { - this.scanIntent - } else { - null - } - - Timber.d( - "Input Source being set to $inputSource. " + - "Validated existing settings to: Res: ${replacementResolution} Intent: $replacementIntent" - ) - copy( - inputSource = newInputSource, - resolution = replacementResolution, - scanIntent = replacementIntent - ) - } - } - } - - fun setResolution(xResolution: UInt, yResolution: UInt) { - coroutineScope.launch { - updateSettings { - copy( - resolution = DiscreteResolution( - widthDPI = xResolution, - heightDPI = yResolution - ) - ) - } - } - } - - fun setIntent(intent: AnyScanEnumOrRaw?) { - coroutineScope.launch { - updateSettings { - copy(scanIntent = intent) + setInputSource(inputSource = newInputSource) } } } - fun setCustomMenuEnabled(enabled: Boolean) { - _uiState.update { - it.copy( - maximumSize = false, - customMenuEnabled = enabled - ) - } - } - - fun setCustomWidthTextFieldContent(width: String) { - check(_uiState.value.customMenuEnabled) - _uiState.update { - it.copy( - maximumSize = false, - widthString = width - ) - } - } - - fun setCustomHeightTextFieldContent(height: String) { - check(_uiState.value.customMenuEnabled) - _uiState.update { - it.copy( - maximumSize = false, - heightString = height - ) - } - } - - private fun unitByLocale(locale: Locale): ScanSettingsLengthUnit = if (locale.country in setOf("US", "LR", "MM")) { - ScanSettingsLengthUnit.INCH - } else { - ScanSettingsLengthUnit.MILLIMETER - } - - fun selectMaxRegion() { - _uiState.update { - it.copy(maximumSize = true) - } - } - - fun setRegionDimension(newWidth: LengthUnit, newHeight: LengthUnit) { - _uiState.update { - it.copy( - maximumSize = false - ) - } + fun setSetting(concept: ScannerConcept, value: Any?) { coroutineScope.launch { updateSettings { - copy( - scanArea = Area( - width = newWidth, - height = newHeight - ) - ) + if (value == null) { + remove(concept) + } else { + @Suppress("UNCHECKED_CAST") + set(concept, value as T) + } } } } - - fun copySettingsToClipboard() { - val systemClipboard = - context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - val scanSettingsString = scanSettings.toString() - systemClipboard.setPrimaryClip( - ClipData.newPlainText( - context.getString(R.string.scan_settings), - scanSettingsString - ) - ) - } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 4acf884f..f234edea 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -38,7 +38,9 @@ import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.ScanSettingsMap import io.github.chrisimx.anyscan.inches import io.github.chrisimx.anyscan.millimeters import io.github.chrisimx.anyscan.threeHundredthsOfInch @@ -62,10 +64,7 @@ import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull 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.coerceIn import io.github.chrisimx.scanbridge.util.getEditedImageName -import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.rotateBy90 import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage @@ -291,14 +290,32 @@ class ScanningScreenViewModel( Timber.d("Stored session: $storedSession") - val updateSettings: suspend (CommonScanSettings.() -> CommonScanSettings) -> Unit = { lambda -> + val updateSettings: suspend (CommonScanSettingsEditor.() -> Unit) -> Unit = { edit -> db.useWriterConnection { it.immediateTransaction { - val oldSession = sessionDao.getSessionById(sessionID) ?: return@immediateTransaction + val oldSession = sessionDao.getSessionById(sessionID) + ?: return@immediateTransaction + + val oldSettings = oldSession.currentScanSettings + ?: CommonScanSettings( + setting = ScanSettingsMap.empty() + ) + + val editor = CommonScanSettingsEditor( + capabilities = _scanningScreenData.capabilities.value!!, + initial = oldSettings + ) + + editor.edit() + + val newSettings = editor.build() + val newSession = oldSession.copy( - currentScanSettings = oldSession.currentScanSettings?.lambda() + currentScanSettings = newSettings ) - Timber.d("Settings updated ${newSession.currentScanSettings}") + + Timber.d("Settings updated $newSettings") + sessionDao.update(newSession) } } @@ -323,16 +340,18 @@ class ScanningScreenViewModel( val savedSettingsPair = DefaultScanSettingsStore.load(application.applicationContext) val (savedSettings, savedSettingsUiState) = savedSettingsPair val initialSettings = if (savedSettings != null) { - try { - val coercedSettings = savedSettings.coerceIn(caps) - - coercedSettings - } catch (e: Exception) { - Timber.e(e, "Error applying saved settings, using defaults") - caps.calculateDefaultESCLScanSettingsState() - } + val editor = CommonScanSettingsEditor( + caps, + savedSettings + ) + editor.build() } else { - caps.calculateDefaultESCLScanSettingsState() + // TODO: Use actual defaults here and use swappable default providers + val editor = CommonScanSettingsEditor( + caps, + CommonScanSettings(setting = ScanSettingsMap.empty()) + ) + editor.build() } val savedSettingsUiStateWithCaps = savedSettingsUiState?.copy(capabilities = caps) @@ -445,7 +464,7 @@ class ScanningScreenViewModel( onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null ) { - val currentScans = scannedPages.value + /*val currentScans = scannedPages.value val scannerCapsNullable = scanningScreenData.capabilities val scannerCaps = if (scannerCapsNullable == null) { onError(application.getString(R.string.scannercapabilities_null)) @@ -577,7 +596,7 @@ class ScanningScreenViewModel( } else { setFileToSave(outputFile) saveFileLauncher.launch(outputFile.name) - } + }*/ } fun doZipExport(context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null) { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt index d3291e4f..1e56d564 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt @@ -20,6 +20,7 @@ object AppSettingsMigrationV0To1 : DataMigration { } override suspend fun migrate(currentData: ScanBridgeSettings): ScanBridgeSettings { + /* val json = ScanSettingsJson.json val oldScanSettingsString = currentData.lastUsedScanSettings?.value val oldScanSettingsParsed = oldScanSettingsString?.let { @@ -70,7 +71,9 @@ object AppSettingsMigrationV0To1 : DataMigration { if (newEnterableData != null) { lastUsedScanSettingsUiState = StringValue.of(newEnterableData) } - } + }*/ + + return currentData } override suspend fun cleanUp() { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt index f270329a..2a3dd930 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt @@ -57,7 +57,8 @@ object LegacyCustomScannerStore { val scanner = CustomScanner( name = name, url = Url(url), - uuid = Uuid.parse(uuid) + uuid = Uuid.parse(uuid), + protocolIdentifier = "eSCL" ) scannerList.add(scanner) } catch (e: IllegalArgumentException) { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt index 027d52c1..5db4d7fd 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.immediateTransaction import androidx.room.useWriterConnection import io.github.chrisimx.esclkt.ScannerCapabilities +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction import io.github.chrisimx.scanbridge.ScanSettingsJson import io.github.chrisimx.scanbridge.data.model.LegacySessionV2 import io.github.chrisimx.scanbridge.data.model.LegacySessionV2.Companion.fromString @@ -106,7 +107,7 @@ object LegacySessionsStore { private suspend fun ScanBridgeDb.insertLegacySessionData(sessionId: Uuid, legacySession: LegacySessionV2) { sessionDao().insertAll( - Session(sessionId, legacySession.scanSettings, null) + Session(sessionId, legacySession.scanSettings?.toCommonAbstraction(), null) ) tmpFileDao().insertAllList( @@ -121,7 +122,7 @@ object LegacySessionsStore { scanId = Uuid.generateV4(), ownerSessionId = sessionId, filePath = page.filePath, - originalScanSettings = page.originalScanSettings, + originalScanSettings = page.originalScanSettings.toCommonAbstraction(), rotation = page.rotation, orderIndex = index ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 67b8e56f..0583ea75 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -75,67 +75,6 @@ fun T.takeIfContainedElseFirstOrNull(list: List): T? { } } -fun CommonScanSettings.coerceIn(capabilities: CommonScannerCapabilities): CommonScanSettings { - val inputSourceType = this.inputSource ?: CommonInputSourceType.PLATEN - val inputSourceCaps = capabilities.getInputSourceCaps(inputSourceType) - ?: capabilities.inputSources.firstOrNull() - ?: throw IllegalArgumentException("No input sources found in capabilities.") - - // Coerce resolution - val coercedResolution = this.resolution?.let { res -> - val availableResolutions = inputSourceCaps.supportedResolutions - if (availableResolutions.contains(res)) { - res - } else { - // Find closest resolution - availableResolutions.minByOrNull { - kotlin.math.abs(it.widthDPI.toInt() - res.widthDPI.toInt()) + - kotlin.math.abs(it.heightDPI.toInt() - res.heightDPI.toInt()) - } ?: res - } - } - - // Coerce color mode - val coercedColorMode = this.colorMode?.let { mode -> - if (inputSourceCaps.supportedColorModes.contains(mode)) { - mode - } else { - inputSourceCaps.supportedColorModes.firstOrNull() - } - } - - // Coerce scan area - val coercedScanArea = this.scanArea?.let { area -> - val minWidth = inputSourceCaps.minSize.width.toMillimeters().value - val maxWidth = inputSourceCaps.maxSize.width.toMillimeters().value - val minHeight = inputSourceCaps.minSize.height.toMillimeters().value - val maxHeight = inputSourceCaps.maxSize.height.toMillimeters().value - - area.copy( - width = Millimeters(area.width.toMillimeters().value.coerceIn(minWidth, maxWidth)), - height = Millimeters(area.height.toMillimeters().value.coerceIn(minHeight, maxHeight)) - ) - } - - // Coerce format - val coercedFormat = this.format?.takeIfContainedElseFirstOrNull( - inputSourceCaps.supportedFileFormats - ) - - // Coerce intent - val coercedIntent = this.scanIntent?.takeIfContainedElseFirstOrNull( - inputSourceCaps.supportedIntents - ) - - return this.copy( - inputSource = inputSourceType, - resolution = coercedResolution, - colorMode = coercedColorMode, - scanArea = coercedScanArea, - format = coercedFormat, - scanIntent = coercedIntent - ) -} fun AnyScanEnumOrRaw.localizedString(context: Context): String = when (this) { is AnyScanEnumOrRaw.Known -> when (this.value) { ColorMode.BlackAndWhite1 -> context.getString(R.string.black_and_white) @@ -157,39 +96,3 @@ fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteReso return maxResolution } - -fun CommonScannerCapabilities.getBestColorMode(inputSource: InputSource): EsclColorModeEnumOrRaw? { - val inputCaps = this.getInputSourceCaps(inputSource) - val chosenColorMode = inputCaps.settingProfiles.elementAtOrNull(0)?.colorModes?.maxByOrNull { - when (it) { - is CSAEnumOrRaw.Known -> it.value.ordinal - is CSAEnumOrRaw.Unknown -> 0 - } - } - return chosenColorMode -} - -fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): ScanSettings { - val inputSource = this.getInputSourceOptions().firstOrNull() ?: InputSource.Platen - - val maxResolution = getMaxResolution(inputSource) - - val inputSourceCaps = this.getInputSourceCaps(inputSource, false) - - val maxScanRegion = scanRegion(inputSourceCaps) { - maxHeight() - maxWidth() - } - - val bestColorMode = getBestColorMode(inputSource) - - return ScanSettings( - version = this.interfaceVersion, - inputSource = inputSource, - scanRegions = maxScanRegion, - xResolution = maxResolution.xResolution, - yResolution = maxResolution.yResolution, - colorMode = bestColorMode, - documentFormatExt = "image/jpeg" - ) -} diff --git a/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json index 204ae79a..e4690b49 100644 --- a/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json +++ b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 5, - "identityHash": "d3761fdca3862892813e41388ac28a43", + "identityHash": "967a7f604f397eeb0b79f656da762e6d", "entities": [ { "tableName": "customscanners", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`uuid`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `protocolIdentifier` TEXT NOT NULL DEFAULT 'eSCL', PRIMARY KEY(`uuid`))", "fields": [ { "fieldPath": "uuid", @@ -25,6 +25,13 @@ "columnName": "url", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "protocolIdentifier", + "columnName": "protocolIdentifier", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'eSCL'" } ], "primaryKey": { @@ -256,7 +263,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd3761fdca3862892813e41388ac28a43')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '967a7f604f397eeb0b79f656da762e6d')" ] } } \ No newline at end of file diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt index 3c46f269..1a920a8e 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt @@ -1,5 +1,7 @@ package io.github.chrisimx.scanbridge +import io.github.chrisimx.anyscan.AnyScanEnumOrRaw +import io.github.chrisimx.anyscan.Area import io.github.chrisimx.anyscan.Inches import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.Millimeters @@ -20,7 +22,13 @@ object ScanSettingsJson { subclass(ThreeHundredthsOfInch::class) subclass(Points::class) } + polymorphic(Any::class) { + subclass(Area::class) + subclass(AnyScanEnumOrRaw.Known::class) + subclass(AnyScanEnumOrRaw.Unknown::class) + } } + allowStructuredMapKeys = true classDiscriminator = "type" prettyPrint = false } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/CustomScanner.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/CustomScanner.kt index 10406365..26ed7d32 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/CustomScanner.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/CustomScanner.kt @@ -1,5 +1,6 @@ package io.github.chrisimx.scanbridge.db.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import io.ktor.http.Url @@ -12,5 +13,7 @@ data class CustomScanner( @PrimaryKey val uuid: Uuid, val name: String, - val url: Url + val url: Url, + @ColumnInfo(defaultValue = "eSCL") + val protocolIdentifier: String ) From 15b64e5776fbca360928df115169213be9d53083 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Mon, 13 Jul 2026 12:55:53 +0200 Subject: [PATCH 12/38] Fix (most pressing) serialization issues --- androidApp/adb+.sh | 2 +- .../chrisimx/scanbridge/ScanSettings.kt | 24 +++++------------ .../ui/ScanSettingsComposableStateHolder.kt | 17 +++++------- .../data/ui/ScanningScreenViewModel.kt | 6 +++++ .../scanbridge/util/ESCLKtExtensions.kt | 10 +------ .../chrisimx/scanbridge/ScanSettingsJson.kt | 5 ++-- ...annerCapabilityMapJsonSerializationTest.kt | 26 +++++++++++++++++++ 7 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 core/src/commonTest/kotlin/ScannerCapabilityMapJsonSerializationTest.kt diff --git a/androidApp/adb+.sh b/androidApp/adb+.sh index 0d102367..b387f6e6 100755 --- a/androidApp/adb+.sh +++ b/androidApp/adb+.sh @@ -1,7 +1,7 @@ #!/bin/bash # Script adb+ # Usage -# You can run any command adb provide on all your current devices +# You can run any command adb provides on all your current devices # ./adb+ is the equivalent of ./adb -s # # Examples diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 87e84880..0c37c46a 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -19,21 +19,17 @@ package io.github.chrisimx.scanbridge -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.InputChip -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -53,20 +49,12 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import io.github.chrisimx.anyscan.Choice -import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.DiscreteResolution import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept -import io.github.chrisimx.anyscan.equalsLength import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit -import io.github.chrisimx.scanbridge.model.NumberValidationResult -import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView -import io.github.chrisimx.scanbridge.uicomponents.ValidatedDimensionsTextEdit -import io.github.chrisimx.scanbridge.util.localizedString import io.github.chrisimx.scanbridge.util.toReadableString -import timber.log.Timber @OptIn( ExperimentalLayoutApi::class, @@ -80,7 +68,7 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp val context = LocalContext.current val vmData by scanSettingsStateHolder.uiState.collectAsState() - val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexCurrentlyActive.collectAsState() + val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexSettingAvailable.collectAsState() val inputSourceOptions by scanSettingsStateHolder.inputSourceOptions.collectAsState() @@ -152,18 +140,18 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp is ScanSettingParam.ScanSettingChoiceParam<*> -> { SelectionCardWithDefault( stringResource(scannerConceptLocalizedName), - parameter.availableChoices, + parameter.availableChoices.map { it.value }, { selectedChoice -> - scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice?.value) + scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice) }, - { this.value.toString() }, - scanSettings.setting[parameter.concept] as Choice? + { this.toString() }, + scanSettings.setting[parameter.concept] ) } is ScanSettingParam.ScanSettingDoubleParam -> TODO() is ScanSettingParam.ScanSettingFloatParam -> TODO() is ScanSettingParam.ScanSettingIntParam -> TODO() - is ScanSettingParam.ScanSettingRegionParam -> TODO() + is ScanSettingParam.ScanSettingRegionParam -> {} } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 04a8e507..6d54ca62 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -25,7 +25,6 @@ import io.github.chrisimx.anyscan.CommonInputSourceType import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.LengthUnit -import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.scanbridge.model.Locale import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 @@ -39,11 +38,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam @@ -90,8 +84,9 @@ class ScanSettingsComposableStateHolder( } != null } - val duplexCurrentlyActive: StateFlow = combine(duplexAdfSupported, scanSettings) { duplexSupport, scanSettings -> - duplexSupport && scanSettings.inputSource == CommonInputSourceType.ADF_DUPLEX + val duplexSettingAvailable: StateFlow = combine(duplexAdfSupported, scanSettings) { duplexSupport, scanSettings -> + duplexSupport && + (scanSettings.inputSource in setOf(CommonInputSourceType.ADF_DUPLEX, CommonInputSourceType.ADF_SIMPLEX)) }.stateIn(coroutineScope, SharingStarted.Lazily, false) private val selectedInputSourceCaps: StateFlow = combine(scanSettings, _uiState) { settings, uiState -> @@ -124,7 +119,7 @@ class ScanSettingsComposableStateHolder( } init { - _uiState + /*_uiState .map { it.maximumSize } .distinctUntilChanged() .combine(selectedInputSourceCaps) { maxSize, inputSourceCaps -> Pair(maxSize, inputSourceCaps) } @@ -138,11 +133,11 @@ class ScanSettingsComposableStateHolder( set(ScannerConcept.ScanRegion, maxArea) } } - }.launchIn(coroutineScope) + }.launchIn(coroutineScope)*/ } fun setDuplex(duplex: Boolean) { - val duplexCurrentlyActive = duplexCurrentlyActive.value + val duplexCurrentlyActive = duplexSettingAvailable.value if (duplex && !duplexCurrentlyActive) { Timber.d("Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyActive") diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index f234edea..3f485e22 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -40,10 +40,12 @@ import com.itextpdf.layout.element.Image import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.FileFormat import io.github.chrisimx.anyscan.ScanSettingsMap import io.github.chrisimx.anyscan.inches import io.github.chrisimx.anyscan.millimeters import io.github.chrisimx.anyscan.threeHundredthsOfInch +import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.ScanRegion import io.github.chrisimx.scanbridge.R @@ -351,6 +353,10 @@ class ScanningScreenViewModel( caps, CommonScanSettings(setting = ScanSettingsMap.empty()) ) + + editor.setInputSource(caps.inputSources.first().inputSourceType) + editor.setFormat(AnyScanEnumOrRaw.Known(FileFormat.JPEG)) + editor.build() } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 0583ea75..789b0956 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -22,21 +22,13 @@ package io.github.chrisimx.scanbridge.util import android.content.Context import android.icu.text.DecimalFormat import app.cash.paraphrase.getString -import io.github.chrisimx.anyscan.AnyScanEnumOrRaw +import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw import io.github.chrisimx.anyscan.ColorMode -import io.github.chrisimx.anyscan.CommonInputSourceType -import io.github.chrisimx.anyscan.CommonScanSettings -import io.github.chrisimx.anyscan.CommonScannerCapabilities -import io.github.chrisimx.anyscan.Millimeters import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.JobState -import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities import io.github.chrisimx.esclkt.getInputSourceCaps -import io.github.chrisimx.esclkt.getInputSourceOptions -import io.github.chrisimx.esclkt.scanRegion import io.github.chrisimx.scanbridge.FormattedResources import io.github.chrisimx.scanbridge.R diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt index 1a920a8e..7a98fa3b 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt @@ -1,7 +1,7 @@ package io.github.chrisimx.scanbridge -import io.github.chrisimx.anyscan.AnyScanEnumOrRaw import io.github.chrisimx.anyscan.Area +import io.github.chrisimx.anyscan.DiscreteResolution import io.github.chrisimx.anyscan.Inches import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.Millimeters @@ -24,8 +24,7 @@ object ScanSettingsJson { } polymorphic(Any::class) { subclass(Area::class) - subclass(AnyScanEnumOrRaw.Known::class) - subclass(AnyScanEnumOrRaw.Unknown::class) + subclass(DiscreteResolution::class) } } allowStructuredMapKeys = true diff --git a/core/src/commonTest/kotlin/ScannerCapabilityMapJsonSerializationTest.kt b/core/src/commonTest/kotlin/ScannerCapabilityMapJsonSerializationTest.kt new file mode 100644 index 00000000..10d0b8da --- /dev/null +++ b/core/src/commonTest/kotlin/ScannerCapabilityMapJsonSerializationTest.kt @@ -0,0 +1,26 @@ +import io.github.chrisimx.anyscan.Area +import io.github.chrisimx.anyscan.MutableScannerCapabilityMap +import io.github.chrisimx.anyscan.ScanSettingParam +import io.github.chrisimx.anyscan.ScannerCapabilityMap +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.inches +import io.github.chrisimx.anyscan.millimeters +import io.github.chrisimx.scanbridge.ScanSettingsJson +import kotlin.test.Test + +class ScannerCapabilityMapJsonSerializationTest { + @Test + fun serialization() { + val caps = MutableScannerCapabilityMap() + caps += ScanSettingParam.ScanSettingRegionParam( + ScannerConcept.ScanRegion, + Area(200.millimeters(), 200.inches()), + Area(100.millimeters(), 100.inches()), + Area(300.millimeters(), 300.inches()) + ) + + val json = ScanSettingsJson.json + val capsImmutable = ScannerCapabilityMap.fromMutable(caps) + println(json.encodeToString(capsImmutable)) + } +} From 22e125147ac03f1b42bbfff424b35d374d7c7927 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 15 Jul 2026 16:48:59 +0200 Subject: [PATCH 13/38] Use sealed value type hierarchy instead of Any to ease serialization --- .../data/ui/ScanSettingsComposableStateHolder.kt | 3 ++- .../github/chrisimx/scanbridge/ScanSettingsJson.kt | 13 +------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 6d54ca62..8bed991f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -26,6 +26,7 @@ import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.SettingValue import io.github.chrisimx.scanbridge.model.Locale import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.ports.LocaleProvider @@ -172,7 +173,7 @@ class ScanSettingsComposableStateHolder( } } - fun setSetting(concept: ScannerConcept, value: Any?) { + fun setSetting(concept: ScannerConcept, value: Any?) { coroutineScope.launch { updateSettings { if (value == null) { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt index 7a98fa3b..d73a6206 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt @@ -15,18 +15,7 @@ import kotlinx.serialization.modules.subclass object ScanSettingsJson { val json = Json { ignoreUnknownKeys = false - serializersModule = SerializersModule { - polymorphic(LengthUnit::class) { - subclass(Inches::class) - subclass(Millimeters::class) - subclass(ThreeHundredthsOfInch::class) - subclass(Points::class) - } - polymorphic(Any::class) { - subclass(Area::class) - subclass(DiscreteResolution::class) - } - } + serializersModule = SerializersModule {} allowStructuredMapKeys = true classDiscriminator = "type" prettyPrint = false From d54c83aad7c9e32ac432613cc1f348c7b5fbfb33 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 18 Jul 2026 11:14:20 +0200 Subject: [PATCH 14/38] Introduce WSD scanning protocol --- androidApp/src/main/AndroidManifest.xml | 3 + .../scanbridge/ScanBridgeApplication.kt | 5 + .../kotlin/AndroidMulticastLockHandler.kt | 33 +++++ .../ports/multicast/MulticastLockHandler.kt | 6 + .../wsd/WsdScannerDiscoveryBackend.kt | 72 +++++++++++ .../scanbridge/wsd/WsdScanningProtocol.kt | 122 ++++++++++++++++++ 6 files changed, 241 insertions(+) create mode 100644 core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/multicast/MulticastLockHandler.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index a38c8d6b..0b470630 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -9,6 +9,9 @@ + + + () viewModel() single() bind ScanningProtocol::class + single() bind ScanningProtocol::class + single() bind MulticastLockHandler::class } class ScanBridgeApplication : Application() { diff --git a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt b/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt new file mode 100644 index 00000000..6d904b3e --- /dev/null +++ b/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt @@ -0,0 +1,33 @@ +import android.app.Application +import android.content.Context +import android.net.wifi.WifiManager +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler + +class AndroidMulticastLockHandler( + val application: Application, + private val loggerFactory: ScanBridgeLoggerFactory +) : MulticastLockHandler { + + private val _logger = loggerFactory.withClass(this::class) + private val wifiManager = + application.getSystemService(Context.WIFI_SERVICE) as WifiManager + + private val lock = + wifiManager.createMulticastLock("wsd_discovery") + + override fun acquire() { + if (!lock.isHeld) { + _logger.debug { "Acquiring multicast lock" } + lock.acquire() + } + } + + override fun release() { + if (lock.isHeld) { + _logger.debug { "Releasing multicast lock" } + lock.release() + } + } + +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/multicast/MulticastLockHandler.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/multicast/MulticastLockHandler.kt new file mode 100644 index 00000000..52644b22 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/multicast/MulticastLockHandler.kt @@ -0,0 +1,6 @@ +package io.github.chrisimx.scanbridge.ports.multicast + +interface MulticastLockHandler { + fun acquire() + fun release() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt new file mode 100644 index 00000000..ed6b3492 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt @@ -0,0 +1,72 @@ +package io.github.chrisimx.scanbridge.wsd + +import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler +import io.github.chrisimx.wsdkt.wsdiscovery.WsScannerServiceDiscovery +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam + + +@OptIn(ExperimentalAtomicApi::class) +class WsdScannerDiscoveryBackend( + val multicastLockHandler: MulticastLockHandler, + @InjectedParam + val protocol: WsdScanningProtocol, + @InjectedParam + val loggerFactory: ScanBridgeLoggerFactory, + @InjectedParam + val coroutineScope: CoroutineScope +) : ScannerDiscoveryBackend { + private val _logger = loggerFactory.withClass(this::class) + + fun discoveryDebugLog(message: String) { + _logger.debug { message } + } + + val wsScannerServiceDiscovery: WsScannerServiceDiscovery = WsScannerServiceDiscovery( + debugLog = ::discoveryDebugLog + ) + + private val closed = AtomicBoolean(false) + + init { + multicastLockHandler.acquire() + + coroutineScope.launch { + try { + awaitCancellation() + } finally { + close() + } + } + } + + override fun close() { + if (closed.compareAndSet(expectedValue = false, newValue = true)) { + multicastLockHandler.release() + } + } + + override val scanners: StateFlow> + get() = wsScannerServiceDiscovery.discoveredDevices.map { scannerList -> + scannerList.map { scannerService -> + DiscoveredScanner( + name = scannerService.name, + handle = UrlScannerHandle(protocol, scannerService.url), + scannerCaps = null, + iconUrl = null + ) + } + }.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt new file mode 100644 index 00000000..75e86e53 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt @@ -0,0 +1,122 @@ +package io.github.chrisimx.scanbridge.wsd + +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.ScanningError +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.model.toHttpClientConfig +import io.github.chrisimx.scanbridge.ports.HttpClientFactory +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler +import io.github.chrisimx.wsdkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.wsdkt.client.WsdScanServiceClient +import io.ktor.http.Url +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow + +class WsdScanningProtocol( + private val loggerFactory: ScanBridgeLoggerFactory, + private val httpClientFactory: HttpClientFactory, + private val multicastLockHandler: MulticastLockHandler +) : ScanningProtocol { + private val _logger = loggerFactory.withClass(this::class) + + override val protocolIdentifier: String + get() = "WSD" + override val usesUrls: Boolean + get() = true + + override fun createScannerHandle(scannerIdentifier: String): ScannerHandle? { + + val url = runCatching { Url(scannerIdentifier) }.getOrNull() + + return url?.let { + UrlScannerHandle( + this, + it + ) + } + } + + override fun createDiscoveryBackend(coroutineScope: CoroutineScope, settings: ScannerConnectionSettings): ScannerDiscoveryBackend = + WsdScannerDiscoveryBackend( + protocol = this, + loggerFactory = loggerFactory, + multicastLockHandler = multicastLockHandler, + coroutineScope = coroutineScope + ) + + override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { + val scannerUrlHandle = + scanner as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) + + val httpConfig = settings.toHttpClientConfig() + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = WsdScanServiceClient(scannerUrlHandle.url, httpClient) + + val allScannerElementsResult = httpClient.use { + esclRequestClient.retrieveAllScannerElements() + } + + when (allScannerElementsResult) { + is WsdScanServiceClient.RetrieveAllScannerElementsResult.RequestFailure -> return ScannerCapabilitiesResult.Failure( + allScannerElementsResult.error + ) + WsdScanServiceClient.RetrieveAllScannerElementsResult.ScannerConfigurationNotFound -> return ScannerCapabilitiesResult.Failure( + allScannerElementsResult + ) // TODO: Map UnknownCertificate error here to the right type + is WsdScanServiceClient.RetrieveAllScannerElementsResult.Success -> {} + } + + val commonScanCaps = allScannerElementsResult + .allScannerElements + .toCommonAbstraction() + + return ScannerCapabilitiesResult.Success(commonScanCaps) + } + + + override fun executeScanJob( + handle: ScannerHandle, + settings: ScannerConnectionSettings, + jobScanSettings: CommonScanSettings, + cancelled: StateFlow + ): Flow = flow { + val scannerUrlHandle = + handle as? UrlScannerHandle + + if (scannerUrlHandle == null) { + emit(ScanJobProcessingEvent.Failure( + ScanningError.InvalidScanHandle(handle) + )) + return@flow + } + + val httpConfig = settings.toHttpClientConfig() + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + + suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { + _logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } + scanJob?.cancel() + + emit(ScanJobProcessingEvent.Cancelled) + true + } else { + false + } + + if (abortIfCancelling()) return@flow + + } +} From b7e51bdd1351c37e519794b272a38189d18f13d8 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 00:17:01 +0200 Subject: [PATCH 15/38] Add localization of choice values --- androidApp/build.gradle.kts | 6 +- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 1 - .../chrisimx/scanbridge/ScanSettings.kt | 242 +++++++++--------- .../chrisimx/scanbridge/ScannerBrowser.kt | 2 + .../chrisimx/scanbridge/ScanningScreen.kt | 2 - .../scanbridge/util/ESCLKtExtensions.kt | 30 --- composeUI/build.gradle.kts | 6 +- .../androidMain}/res/values-de/strings.xml | 6 +- .../androidMain}/res/values-it/strings.xml | 6 +- .../src/androidMain}/res/values/strings.xml | 12 +- .../composeResources/values-de/strings.xml | 6 +- .../composeResources/values-it/strings.xml | 6 +- .../composeResources/values/strings.xml | 12 +- .../util/ScanSettingsChoiceLocalization.kt | 79 ++++++ .../util/ScannerConceptLocalization.kt | 18 ++ .../scanbridge/ports/ScanningProtocol.kt | 7 +- 16 files changed, 268 insertions(+), 173 deletions(-) rename {androidApp/src/main => composeUI/src/androidMain}/res/values-de/strings.xml (98%) rename {androidApp/src/main => composeUI/src/androidMain}/res/values-it/strings.xml (97%) rename {androidApp/src/main => composeUI/src/androidMain}/res/values/strings.xml (94%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 4fca9aa5..aa409b6a 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -76,8 +76,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { compose = true @@ -87,7 +87,7 @@ android { kotlin { compilerOptions { - jvmTarget = JvmTarget.JVM_17 + jvmTarget = JvmTarget.JVM_21 optIn.add("kotlin.uuid.ExperimentalUuidApi") freeCompilerArgs.add("-Xnon-local-break-continue") } diff --git a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index 87c5978d..0a7516c6 100644 --- a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -21,7 +21,6 @@ package io.github.chrisimx.scanbridge import android.app.Application import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 0c37c46a..31d1a876 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -19,7 +19,7 @@ package io.github.chrisimx.scanbridge -import androidx.compose.foundation.ExperimentalFoundationApi +import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -39,9 +39,6 @@ import androidx.compose.material3.ToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -49,18 +46,16 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import io.github.chrisimx.anyscan.DiscreteResolution +import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit +import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView +import io.github.chrisimx.scanbridge.util.UIInputSourceType +import io.github.chrisimx.scanbridge.util.toLocalizedName import io.github.chrisimx.scanbridge.util.toReadableString -@OptIn( - ExperimentalLayoutApi::class, - ExperimentalFoundationApi::class -) -private val TAG = "ScanSettings" @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -96,58 +91,26 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.input_source)) - FlowRow( - Modifier.fillMaxWidth().padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) - ) { - SingleChoiceSegmentedButtonRow { - inputSourceOptions.forEachIndexed { index, inputSource -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = inputSourceOptions.size - ), - onClick = { scanSettingsStateHolder.setInputSource(inputSource) }, - selected = selectedInputSource == inputSource - ) { - Text(inputSource.toReadableString(context)) - } - } - } - ToggleButton( - enabled = duplexCurrentlyAvailable, - checked = duplexUsed, - onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } - ) { Text(stringResource(R.string.setting_duplex)) } - } - - var fitsRowVersion by remember { mutableStateOf(false) } + InputSourceSelection( + inputSourceOptions, + scanSettingsStateHolder, + selectedInputSource, + context, + duplexCurrentlyAvailable, + duplexUsed + ) for (parameterPair in availableParameters) { val (scannerConcept, parameter) = parameterPair - val scannerConceptLocalizedName = when (scannerConcept) { - ScannerConcept.ColorMode -> R.string.color_mode - ScannerConcept.ScanIntent -> R.string.intent - ScannerConcept.ScanRegion -> R.string.scan_region - ScannerConcept.ScanResolution -> R.string.resolution_dpi - } + val scannerConceptLocalizedName = scannerConcept.toLocalizedName() when (parameter) { is ScanSettingParam.ScanSettingBoolParam -> TODO() is ScanSettingParam.ScanSettingChoiceParam<*> -> { - SelectionCardWithDefault( - stringResource(scannerConceptLocalizedName), - parameter.availableChoices.map { it.value }, - { selectedChoice -> - scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice) - }, - { this.toString() }, - scanSettings.setting[parameter.concept] - ) + ChoiceParameterDisplay(scannerConceptLocalizedName, parameter, scanSettingsStateHolder, scannerConcept, scanSettings) } + is ScanSettingParam.ScanSettingDoubleParam -> TODO() is ScanSettingParam.ScanSettingFloatParam -> TODO() is ScanSettingParam.ScanSettingIntParam -> TODO() @@ -158,13 +121,88 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp } @Composable -private fun SelectionCardWithDefault( +private fun ChoiceParameterDisplay( + scannerConceptLocalizedName: String, + parameter: ScanSettingParam.ScanSettingChoiceParam<*>, + scanSettingsStateHolder: ScanSettingsComposableStateHolder, + scannerConcept: ScannerConcept<*>, + scanSettings: CommonScanSettings +) { + SizeBasedConditionalView( + largeView = { + SelectionButtonRow( + scannerConceptLocalizedName, + parameter.availableChoices.map { it.value }, + { selectedChoice -> + scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice) + }, + { this.toLocalizedName(scannerConcept) }, + scanSettings.setting[parameter.concept] + ) + }, + smallView = { + SelectionCard( + scannerConceptLocalizedName, + parameter.availableChoices.map { it.value }, + { selectedChoice -> + scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice) + }, + { this.toLocalizedName(scannerConcept) }, + scanSettings.setting[parameter.concept] + ) + } + ) +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun InputSourceSelection( + inputSourceOptions: List, + scanSettingsStateHolder: ScanSettingsComposableStateHolder, + selectedInputSource: UIInputSourceType, + context: Context, + duplexCurrentlyAvailable: Boolean, + duplexUsed: Boolean +) { + Text(stringResource(R.string.input_source)) + FlowRow( + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) + ) { + SingleChoiceSegmentedButtonRow { + inputSourceOptions.forEachIndexed { index, inputSource -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = inputSourceOptions.size + ), + onClick = { scanSettingsStateHolder.setInputSource(inputSource) }, + selected = selectedInputSource == inputSource + ) { + Text(inputSource.toReadableString(context)) + } + } + } + ToggleButton( + enabled = duplexCurrentlyAvailable, + checked = duplexUsed, + onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } + ) { Text(stringResource(R.string.setting_duplex)) } + } +} + +@Composable +private fun SelectionCard( title: String, options: List, onSet: (T?) -> Unit, - stringify: T.() -> String, + stringify: @Composable T.() -> String, value: T?, - isSmallRowAbove: Boolean = false + isSmallRowAbove: Boolean = false, + hasDefaultOption: Boolean = false ) { OutlinedCard( modifier = Modifier @@ -193,88 +231,48 @@ private fun SelectionCardWithDefault( selected = value == option ) } - InputChip( - onClick = { - onSet(null) - }, - label = { Text(stringResource(R.string.default_string)) }, - selected = value == null - ) + if (hasDefaultOption) { + InputChip( + onClick = { + onSet(null) + }, + label = { Text(stringResource(R.string.default_string)) }, + selected = value == null + ) + } } } } } @Composable -private fun ResolutionSettingButtonRowVersion( - supportedResolutions: List, - currentResolution: DiscreteResolution?, - setSelectedResolution: (UInt, UInt) -> Unit +private fun SelectionButtonRow( + title: String, + options: List, + onSet: (T?) -> Unit, + stringify: @Composable T.() -> String, + value: T?, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.resolution_dpi)) + Text( + title, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) SingleChoiceSegmentedButtonRow { - supportedResolutions.forEachIndexed { index, discreteResolution -> + options.forEachIndexed { index, option -> + val name = option.stringify() SegmentedButton( shape = SegmentedButtonDefaults.itemShape( index = index, - count = supportedResolutions.size + count = options.size ), onClick = { - setSelectedResolution(discreteResolution.widthDPI, discreteResolution.heightDPI) + onSet(option) }, - selected = currentResolution == discreteResolution + selected = option == value ) { - if (discreteResolution.widthDPI == discreteResolution.heightDPI) { - Text("${discreteResolution.widthDPI}") - } else { - Text("${discreteResolution.widthDPI}x${discreteResolution.heightDPI}") - } - } - } - } - } -} - -@Composable -private fun ResolutionSettingCardVersion( - supportedResolutions: List, - currentResolution: DiscreteResolution?, - setSelectedResolution: (UInt, UInt) -> Unit -) { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 30.dp, bottom = 15.dp) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - stringResource(R.string.resolution_dpi), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - - FlowRow( - Modifier.fillMaxWidth(), - - horizontalArrangement = Arrangement.SpaceEvenly - ) { - supportedResolutions.forEach { discreteResolution -> - val text = if (discreteResolution.widthDPI == discreteResolution.heightDPI) { - "${discreteResolution.widthDPI}" - } else { - "${discreteResolution.widthDPI}x${discreteResolution.heightDPI}" - } - InputChip( - onClick = { - setSelectedResolution( - discreteResolution.widthDPI, - discreteResolution.heightDPI - ) - }, - label = { Text(text) }, - selected = currentResolution == discreteResolution - ) + Text(name) } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index d8236825..193c509d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -19,6 +19,7 @@ package io.github.chrisimx.scanbridge +import android.annotation.SuppressLint import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -188,6 +189,7 @@ fun ScannerBrowser( CustomScannerDialog( onDismiss = { setEditedCustomDialog(null) }, onConnectClicked = { name, url, save, navigate -> + @SuppressLint("LocalContextGetResourceValueCall") val name = name.ifEmpty { context.getString(R.string.custom_scanner) } val url = if (url.toString().endsWith("/")) url.toString() else "$url/" val sessionID = Uuid.random() diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 17163380..f3ba8b27 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -54,7 +54,6 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -65,7 +64,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 789b0956..6a027dac 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -21,15 +21,7 @@ package io.github.chrisimx.scanbridge.util import android.content.Context import android.icu.text.DecimalFormat -import app.cash.paraphrase.getString -import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw -import io.github.chrisimx.anyscan.ColorMode -import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.JobState -import io.github.chrisimx.esclkt.ScannerCapabilities -import io.github.chrisimx.esclkt.getInputSourceCaps -import io.github.chrisimx.scanbridge.FormattedResources import io.github.chrisimx.scanbridge.R fun JobState?.toJobStateString(context: Context): String = when (this) { @@ -66,25 +58,3 @@ fun T.takeIfContainedElseFirstOrNull(list: List): T? { list.firstOrNull() } } - -fun AnyScanEnumOrRaw.localizedString(context: Context): String = when (this) { - is AnyScanEnumOrRaw.Known -> when (this.value) { - ColorMode.BlackAndWhite1 -> context.getString(R.string.black_and_white) - ColorMode.RGB24 -> context.getString(FormattedResources.color_scan("24")) - ColorMode.RGB48 -> context.getString(FormattedResources.color_scan("48")) - ColorMode.AutoColorDetection -> context.getString(R.string.auto_detect) - ColorMode.Grayscale8 -> context.getString(FormattedResources.grayscale("8")) - ColorMode.Grayscale16 -> context.getString(FormattedResources.grayscale("16")) - } - - is AnyScanEnumOrRaw.Unknown -> this.asString() -} - -fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteResolution { - val inputCaps = this.getInputSourceCaps(inputSource) - val maxResolution = inputCaps - .settingProfiles.first() - .supportedResolutions.discreteResolutions.maxBy { it.xResolution * it.yResolution } - - return maxResolution -} diff --git a/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index a7dc95b6..2a5e5a42 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -11,7 +11,7 @@ plugins { kotlin { jvm { - compilerOptions { jvmTarget = JvmTarget.JVM_17 } + compilerOptions { jvmTarget = JvmTarget.JVM_21 } } android { @@ -22,7 +22,7 @@ kotlin { compilerOptions { jvmTarget.set( - org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 ) } } @@ -35,6 +35,8 @@ kotlin { api(libs.compose.resources) api(libs.compose.ui.tooling.preview) api(libs.compose.material3) + + api(libs.anyscanCore) } commonTest.dependencies { diff --git a/androidApp/src/main/res/values-de/strings.xml b/composeUI/src/androidMain/res/values-de/strings.xml similarity index 98% rename from androidApp/src/main/res/values-de/strings.xml rename to composeUI/src/androidMain/res/values-de/strings.xml index a56588bb..959c5dae 100644 --- a/androidApp/src/main/res/values-de/strings.xml +++ b/composeUI/src/androidMain/res/values-de/strings.xml @@ -117,9 +117,11 @@ Gescannte Seiten werden im Hintergrund abgerufen. Farbmodus: Schwarz-Weiß - Farbe ({bitdepth} bit) + Farbe (24 bit) + Farbe (48 bit) Automatische Erkennung - Graustufen ({bitdepth} bit) + Graustufen (8 bit) + Graustufen (16 bit) Speichern Benutzerdefinierten Scanner bearbeiten Willst du den benutzerdefinierten Scanner wirklich löschen? Dieser Vorgang ist unwiderruflich. diff --git a/androidApp/src/main/res/values-it/strings.xml b/composeUI/src/androidMain/res/values-it/strings.xml similarity index 97% rename from androidApp/src/main/res/values-it/strings.xml rename to composeUI/src/androidMain/res/values-it/strings.xml index 7ebb1aa4..d3ba5551 100644 --- a/androidApp/src/main/res/values-it/strings.xml +++ b/composeUI/src/androidMain/res/values-it/strings.xml @@ -116,11 +116,13 @@ Le pagine scansionate vengono recuperate in background. Modalità colore: Bianco e nero - Colore ({bitdepth} bit) + Colore (48 bit) Rilevamento automatico - Scala di grigi ({bitdepth} bit) + Scala di grigi (16 bit) Salva Modifica scanner personalizzato Vuoi davvero eliminare lo scanner personalizzato? Questa operazione è irreversibile. Elimina scanner personalizzato + Colore (24 bit) + Scala di grigi (8 bit) \ No newline at end of file diff --git a/androidApp/src/main/res/values/strings.xml b/composeUI/src/androidMain/res/values/strings.xml similarity index 94% rename from androidApp/src/main/res/values/strings.xml rename to composeUI/src/androidMain/res/values/strings.xml index 1d72524c..79e19780 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/composeUI/src/androidMain/res/values/strings.xml @@ -119,11 +119,19 @@ Scan job running… Color Mode: - Color ({bitdepth} bit) + Color (24 bit) + Color (48 bit) Automatic detection - Grayscale ({bitdepth} bit) + Grayscale (8 bit) + Grayscale (16 bit) Save Edit custom scanner Do you really want to delete this custom scanner? This action cannot be undone. Delete custom scanner + Document + + Photo + Preview + 3D Object + Business Card \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index a56588bb..959c5dae 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -117,9 +117,11 @@ Gescannte Seiten werden im Hintergrund abgerufen. Farbmodus: Schwarz-Weiß - Farbe ({bitdepth} bit) + Farbe (24 bit) + Farbe (48 bit) Automatische Erkennung - Graustufen ({bitdepth} bit) + Graustufen (8 bit) + Graustufen (16 bit) Speichern Benutzerdefinierten Scanner bearbeiten Willst du den benutzerdefinierten Scanner wirklich löschen? Dieser Vorgang ist unwiderruflich. diff --git a/composeUI/src/commonMain/composeResources/values-it/strings.xml b/composeUI/src/commonMain/composeResources/values-it/strings.xml index 7ebb1aa4..d3ba5551 100644 --- a/composeUI/src/commonMain/composeResources/values-it/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-it/strings.xml @@ -116,11 +116,13 @@ Le pagine scansionate vengono recuperate in background. Modalità colore: Bianco e nero - Colore ({bitdepth} bit) + Colore (48 bit) Rilevamento automatico - Scala di grigi ({bitdepth} bit) + Scala di grigi (16 bit) Salva Modifica scanner personalizzato Vuoi davvero eliminare lo scanner personalizzato? Questa operazione è irreversibile. Elimina scanner personalizzato + Colore (24 bit) + Scala di grigi (8 bit) \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/values/strings.xml b/composeUI/src/commonMain/composeResources/values/strings.xml index 1d72524c..79e19780 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -119,11 +119,19 @@ Scan job running… Color Mode: - Color ({bitdepth} bit) + Color (24 bit) + Color (48 bit) Automatic detection - Grayscale ({bitdepth} bit) + Grayscale (8 bit) + Grayscale (16 bit) Save Edit custom scanner Do you really want to delete this custom scanner? This action cannot be undone. Delete custom scanner + Document + + Photo + Preview + 3D Object + Business Card \ No newline at end of file diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt new file mode 100644 index 00000000..b85f2e1a --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt @@ -0,0 +1,79 @@ +package io.github.chrisimx.scanbridge.util + +import androidx.compose.runtime.Composable +import io.github.chrisimx.anyscan.ColorMode +import io.github.chrisimx.anyscan.DiscreteResolutionValue +import io.github.chrisimx.anyscan.ScanIntent +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.SettingValue +import io.github.chrisimx.anyscan.StringValue +import io.github.chrisimx.anyscan.toAnyScanEnumOrRaw +import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.* + +@JvmName("localizedColorModeString") +@Composable +fun AnyScanEnumOrRaw.localizedString(): String = when (this) { + is AnyScanEnumOrRaw.Known -> stringResource( + when (this.value) { + ColorMode.BlackAndWhite1 -> Res.string.black_and_white + ColorMode.RGB24 -> Res.string.color_scan_24 + ColorMode.RGB48 -> Res.string.color_scan_48 + ColorMode.AutoColorDetection -> Res.string.auto_detect + ColorMode.Grayscale8 -> Res.string.grayscale_8 + ColorMode.Grayscale16 -> Res.string.grayscale_16 + }) + + is AnyScanEnumOrRaw.Unknown -> this.asString() +} + + +@JvmName("localizedScanIntentString") // Else the declaration would clash on JVM because of type erasure +@Composable +fun AnyScanEnumOrRaw.localizedString(): String = when (this) { + is AnyScanEnumOrRaw.Known -> stringResource( + when (this.value) { + ScanIntent.Document -> Res.string.scan_intent_document + ScanIntent.TextAndGraphic -> Res.string.scan_intent_text_and_graphic + ScanIntent.Photo -> Res.string.scan_intent_photo + ScanIntent.Preview -> Res.string.scan_intent_preview + ScanIntent.Object -> Res.string.scan_intent_object + ScanIntent.BusinessCard -> Res.string.scan_intent_business_card + }) + + is AnyScanEnumOrRaw.Unknown -> this.asString() +} + + +@Composable +fun StringValue.toLocalizedName(scannerConcept: ScannerConcept): String { + return when (scannerConcept) { + ScannerConcept.ColorMode -> { + this.value + .toAnyScanEnumOrRaw() + .localizedString() + } + + ScannerConcept.ScanIntent -> { + this.value + .toAnyScanEnumOrRaw() + .localizedString() + } + else -> this.toString() + } +} + +@Composable +fun SettingValue.toLocalizedName(scannerConcept: ScannerConcept): String { + return when (this) { + is DiscreteResolutionValue -> if (this.value.widthDPI == this.value.heightDPI) { + "${this.value.widthDPI}" + } else { + "${this.value.widthDPI}x${this.value.heightDPI}" + } + is StringValue -> this.toLocalizedName(scannerConcept) + else -> this.toString() + } +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt new file mode 100644 index 00000000..71be6fb6 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt @@ -0,0 +1,18 @@ +package io.github.chrisimx.scanbridge.util + +import androidx.compose.runtime.Composable +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.SettingValue +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.* + +@Composable +fun ScannerConcept.toLocalizedName(): String { + return stringResource(when (this) { + ScannerConcept.ColorMode -> Res.string.color_mode + ScannerConcept.ScanIntent -> Res.string.intent + ScannerConcept.ScanRegion -> Res.string.scan_region + ScannerConcept.ScanResolution -> Res.string.resolution_dpi + }) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt index 5eaf28d0..ffa95c69 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -37,6 +37,11 @@ interface ScanningProtocol { */ val protocolIdentifier: String + /** + * An exemplary string that shows what the scanner identifier string usually looks like. + */ + val exampleScannerIdentifierString: String + /** * Whether this scanning protocol uses URLs for identifying a scanner. * This flag is needed so that it can be decided for which protocols the manual URL entry @@ -48,7 +53,7 @@ interface ScanningProtocol { * Creates a [ScannerHandle] for the given scanner identifier. This handle can be used for the rest of the API. * * The meaning of the string [scannerIdentifier] is protocol dependent. It could be a URL, - * a PCI device number or something else. This is left free to allow support for protocols that + * a PCI device number, or something else. This is left free to allow support for protocols that * do not use URLs. * Within a [ScanningProtocol] this identifier should have * consistent meaning. From fd9f9cbc9d65dde255281c8c8eb88fc7324b8602 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 01:21:34 +0200 Subject: [PATCH 16/38] Add protocol selection for custom scanner creation --- .../chrisimx/scanbridge/ScanSettings.kt | 87 +------------------ .../chrisimx/scanbridge/ScannerBrowser.kt | 12 +-- .../dialog/CustomScannerDialog.kt | 77 ++++++++++++++-- .../src/androidMain/res/values-de/strings.xml | 2 +- .../src/androidMain/res/values-it/strings.xml | 2 +- .../src/androidMain/res/values/strings.xml | 2 +- .../composeResources/values/strings.xml | 1 + .../uicomponents/SelectionButtonRow.kt | 46 ++++++++++ .../scanbridge/uicomponents/SelectionCard.kt | 69 +++++++++++++++ .../scanbridge/escl/EsclScanningProtocol.kt | 18 +++- .../ScannerDiscoveryScreenViewModel.kt | 16 +++- .../scanbridge/wsd/WsdScanningProtocol.kt | 2 + 12 files changed, 230 insertions(+), 104 deletions(-) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 31d1a876..bcd115a8 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -51,12 +51,13 @@ import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit +import io.github.chrisimx.scanbridge.uicomponents.SelectionButtonRow +import io.github.chrisimx.scanbridge.uicomponents.SelectionCard import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView import io.github.chrisimx.scanbridge.util.UIInputSourceType import io.github.chrisimx.scanbridge.util.toLocalizedName import io.github.chrisimx.scanbridge.util.toReadableString - @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComposableStateHolder) { @@ -194,87 +195,3 @@ private fun InputSourceSelection( } } -@Composable -private fun SelectionCard( - title: String, - options: List, - onSet: (T?) -> Unit, - stringify: @Composable T.() -> String, - value: T?, - isSmallRowAbove: Boolean = false, - hasDefaultOption: Boolean = false -) { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = if (isSmallRowAbove) 30.dp else 15.dp, bottom = 15.dp) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - title, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - - FlowRow( - Modifier.fillMaxWidth(), - - horizontalArrangement = Arrangement.SpaceEvenly - ) { - options.forEach { option -> - val name = option.stringify() - InputChip( - onClick = { - onSet(option) - }, - label = { Text(name) }, - selected = value == option - ) - } - if (hasDefaultOption) { - InputChip( - onClick = { - onSet(null) - }, - label = { Text(stringResource(R.string.default_string)) }, - selected = value == null - ) - } - } - } - } -} - -@Composable -private fun SelectionButtonRow( - title: String, - options: List, - onSet: (T?) -> Unit, - stringify: @Composable T.() -> String, - value: T?, -) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - title, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - SingleChoiceSegmentedButtonRow { - options.forEachIndexed { index, option -> - val name = option.stringify() - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = options.size - ), - onClick = { - onSet(option) - }, - selected = option == value - ) { - Text(name) - } - } - } - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index 193c509d..14f3bd82 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -147,6 +147,7 @@ fun ScannerBrowser( val scannerDiscoveryScreenViewModel: ScannerDiscoveryScreenViewModel = koinViewModel() val customScanners by scannerDiscoveryScreenViewModel.customScanners.collectAsState() val discoveredScanners by scannerDiscoveryScreenViewModel.discoveredScanners.collectAsState() + val protocolsForCustomScanners = scannerDiscoveryScreenViewModel.protocolsForCustomScanners var deletionScheduledScanner: Uuid? by remember { mutableStateOf(null) } @@ -187,20 +188,19 @@ fun ScannerBrowser( val context = LocalContext.current CustomScannerDialog( + protocolsWithExampleHandle = protocolsForCustomScanners, onDismiss = { setEditedCustomDialog(null) }, - onConnectClicked = { name, url, save, navigate -> + onConnectClicked = { name, url, protocol, save, navigate -> @SuppressLint("LocalContextGetResourceValueCall") val name = name.ifEmpty { context.getString(R.string.custom_scanner) } - val url = if (url.toString().endsWith("/")) url.toString() else "$url/" val sessionID = Uuid.random() val uuid = when (currentlyEditedScanner) { is EditedCustomScanner.EditingOld -> currentlyEditedScanner.scanner.uuid EditedCustomScanner.New -> Uuid.random() } - val protocolIdentifier = "eSCL" if (save) { scannerDiscoveryScreenViewModel.addScanner( - CustomScanner(uuid, name, Url(url), protocolIdentifier) + CustomScanner(uuid, name, url, protocol) ) } setEditedCustomDialog(null) @@ -208,8 +208,8 @@ fun ScannerBrowser( navController.navigate( ScannerRoute( name, - url, - protocolIdentifier, + url.toString(), + protocol, sessionID.toString() ) ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index 5d01d3f1..ed82c024 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt @@ -1,5 +1,6 @@ package io.github.chrisimx.scanbridge.uicomponents.dialog +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -10,6 +11,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -21,17 +23,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.data.model.EditedCustomScanner +import io.github.chrisimx.scanbridge.scannerdiscovery.ProtocolWithExampleHandleString +import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme +import io.github.chrisimx.scanbridge.uicomponents.SelectionButtonRow import io.ktor.http.Url +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.scanning_protocol @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CustomScannerDialog( + protocolsWithExampleHandle: List, onDismiss: () -> Unit, - onConnectClicked: (name: String, url: Url, save: Boolean, navigate: Boolean) -> Unit, + onConnectClicked: (name: String, url: Url, protocol: String, save: Boolean, navigate: Boolean) -> Unit, editingType: EditedCustomScanner ) { var urlErrorState: String? by remember { mutableStateOf(null) } @@ -39,11 +49,18 @@ fun CustomScannerDialog( is EditedCustomScanner.EditingOld -> editingType.scanner EditedCustomScanner.New -> null } + val originalIdentifier = protocolsWithExampleHandle + .firstOrNull { it.protocolIdentifier == initializeWith?.protocolIdentifier } + var urlText: String by remember { mutableStateOf(initializeWith?.url?.toString() ?: "") } var nameText: String by remember { mutableStateOf(initializeWith?.name ?: "") } + var selectedProtocol by remember { + mutableStateOf(originalIdentifier ?: protocolsWithExampleHandle.first()) + } + val context = LocalContext.current val isNewScanner = editingType is EditedCustomScanner.New @@ -103,8 +120,8 @@ fun CustomScannerDialog( urlErrorState = null urlText = it }, - label = { Text(stringResource(R.string.url_escl_resource)) }, - placeholder = { Text("http://192.168.178.2/eSCL/") }, + label = { Text(stringResource(R.string.custom_scanner_url)) }, + placeholder = { Text(selectedProtocol.exampleScannerIdentifierString) }, supportingText = { urlErrorState?.let { Text( @@ -116,11 +133,25 @@ fun CustomScannerDialog( } ) + SelectionButtonRow( + stringResource(Res.string.scanning_protocol), + protocolsWithExampleHandle, + { selectedProtocol = it!! }, + { this.protocolIdentifier }, + selectedProtocol + ) + if (isNewScanner) { Button( onClick = { val url = validateUrl() ?: return@Button - onConnectClicked(nameText, url, true, true) + onConnectClicked( + nameText, + url, + selectedProtocol.protocolIdentifier, + true, + true + ) }, modifier = Modifier.padding(top = 16.dp) ) { @@ -129,7 +160,13 @@ fun CustomScannerDialog( Button( onClick = { val url = validateUrl() ?: return@Button - onConnectClicked(nameText, url, false, true) + onConnectClicked( + nameText, + url, + selectedProtocol.protocolIdentifier, + false, + true + ) }, modifier = Modifier.padding(top = 8.dp).testTag("justconnect") ) { @@ -139,9 +176,15 @@ fun CustomScannerDialog( Button( onClick = { val url = validateUrl() ?: return@Button - onConnectClicked(nameText, url, true, false) + onConnectClicked( + nameText, + url, + selectedProtocol.protocolIdentifier, + true, + false + ) }, - modifier = Modifier.padding(top = 0.dp).testTag("editcustomscanner") + modifier = Modifier.padding(top = 16.dp).testTag("editcustomscanner") ) { Text(stringResource(R.string.save)) } @@ -150,3 +193,23 @@ fun CustomScannerDialog( } } } + + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@Preview +@Composable +fun PreviewCustomScannerDialog() { + Scaffold { + ScanBridgeTheme { + CustomScannerDialog( + listOf( + ProtocolWithExampleHandleString("test", "test") + ), + {}, + { _, _, _, _, _ -> Unit }, + EditedCustomScanner.New + + ) + } + } +} diff --git a/composeUI/src/androidMain/res/values-de/strings.xml b/composeUI/src/androidMain/res/values-de/strings.xml index 959c5dae..9d063ed9 100644 --- a/composeUI/src/androidMain/res/values-de/strings.xml +++ b/composeUI/src/androidMain/res/values-de/strings.xml @@ -66,7 +66,7 @@ Wird gedreht… Auf einen benutzdefinierten Scanner zugreifen Verbinden - URL (eSCL-Ressource) + URL Verbinden mit benutzerdefinierten Scanner Ungültige URL Gib bitte eine URL ein diff --git a/composeUI/src/androidMain/res/values-it/strings.xml b/composeUI/src/androidMain/res/values-it/strings.xml index d3ba5551..d80dceca 100644 --- a/composeUI/src/androidMain/res/values-it/strings.xml +++ b/composeUI/src/androidMain/res/values-it/strings.xml @@ -64,7 +64,7 @@ Rotazione in corso… Accedi allo scanner personalizzato Connettiti - URL (risorsa eSCL) + URL Connettiti a uno scanner personalizzato URL non valido Per favore inserisci un URL diff --git a/composeUI/src/androidMain/res/values/strings.xml b/composeUI/src/androidMain/res/values/strings.xml index 79e19780..a06733db 100644 --- a/composeUI/src/androidMain/res/values/strings.xml +++ b/composeUI/src/androidMain/res/values/strings.xml @@ -66,7 +66,7 @@ Rotating… Access custom scanner Just connect - URL (eSCL resource) + URL Connect to a custom scanner Invalid URL Please enter an URL diff --git a/composeUI/src/commonMain/composeResources/values/strings.xml b/composeUI/src/commonMain/composeResources/values/strings.xml index 79e19780..d22034a8 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -134,4 +134,5 @@ Preview 3D Object Business Card + Protocol \ No newline at end of file diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt new file mode 100644 index 00000000..3fae785c --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt @@ -0,0 +1,46 @@ +package io.github.chrisimx.scanbridge.uicomponents + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign + +@Composable +fun SelectionButtonRow( + title: String, + options: List, + onSet: (T?) -> Unit, + stringify: @Composable T.() -> String, + value: T?, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + title, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + SingleChoiceSegmentedButtonRow { + options.forEachIndexed { index, option -> + val name = option.stringify() + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = options.size + ), + onClick = { + onSet(option) + }, + selected = option == value + ) { + Text(name) + } + } + } + } +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt new file mode 100644 index 00000000..8c869adf --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt @@ -0,0 +1,69 @@ +package io.github.chrisimx.scanbridge.uicomponents + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.InputChip +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.default_string + +@Composable +fun SelectionCard( + title: String, + options: List, + onSet: (T?) -> Unit, + stringify: @Composable T.() -> String, + value: T?, + isSmallRowAbove: Boolean = false, + hasDefaultOption: Boolean = false +) { + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = if (isSmallRowAbove) 30.dp else 15.dp, bottom = 15.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + title, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + FlowRow( + Modifier.fillMaxWidth(), + + horizontalArrangement = Arrangement.SpaceEvenly + ) { + options.forEach { option -> + val name = option.stringify() + InputChip( + onClick = { + onSet(option) + }, + label = { Text(name) }, + selected = value == option + ) + } + if (hasDefaultOption) { + InputChip( + onClick = { + onSet(null) + }, + label = { Text(stringResource(Res.string.default_string)) }, + selected = value == null + ) + } + } + } + } +} + diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index 42f3a28a..9baee187 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -41,6 +41,8 @@ class EsclScanningProtocol( get() = "eSCL" override val usesUrls: Boolean get() = true + override val exampleScannerIdentifierString: String + get() = "http://192.168.178.122/eSCL/" init { check(mdnsDiscoverySecureEscl !== mdnsDiscoveryInsecureEscl) { @@ -69,6 +71,13 @@ class EsclScanningProtocol( coroutineScope ) + private fun Url.addTrailingSlash(): Url = Url(if (this.toString().endsWith("/")) { + this.toString() + } else { + "$this/" + }) + + override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { val scannerUrlHandle = scanner as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) @@ -76,7 +85,10 @@ class EsclScanningProtocol( val httpConfig = settings.toHttpClientConfig() val httpClient = httpClientFactory.create(httpConfig) - val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + val esclRequestClient = ESCLRequestClient( + baseUrl = scannerUrlHandle.url.addTrailingSlash(), + httpClient + ) val scannerCapsResult = httpClient.use { esclRequestClient.getScannerCapabilities() @@ -181,7 +193,9 @@ class EsclScanningProtocol( val httpConfig = settings.toHttpClientConfig() val httpClient = httpClientFactory.create(httpConfig) - val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + val esclRequestClient = ESCLRequestClient( + scannerUrlHandle.url.addTrailingSlash(), + httpClient) suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { _logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt index bdd80cb3..e4f48c72 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt @@ -6,14 +6,21 @@ import com.rickclephas.kmp.observableviewmodel.launch import com.rickclephas.kmp.observableviewmodel.stateIn import io.github.chrisimx.scanbridge.db.entities.CustomScanner import io.github.chrisimx.scanbridge.ports.CustomScannerRepository +import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +data class ProtocolWithExampleHandleString( + val protocolIdentifier: String, + val exampleScannerIdentifierString: String +) + class ScannerDiscoveryScreenViewModel( val customScannerRepo: CustomScannerRepository, - val discoveryUsecase: DiscoveryUsecase + val discoveryUsecase: DiscoveryUsecase, + val protocolManager: ScanningProtocolManager ) : ViewModel() { private val _customScanners = customScannerRepo.allFlow() @@ -22,6 +29,13 @@ class ScannerDiscoveryScreenViewModel( val discoveredScanners = discoveryUsecase.discoveredScanners(viewModelScope.coroutineScope) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + /** + * The protocols for which we can create a custom scanner. + */ + val protocolsForCustomScanners = protocolManager + .getAllProtocols() + .map { ProtocolWithExampleHandleString(it.protocolIdentifier, it.exampleScannerIdentifierString) } + fun addScanner(scanner: CustomScanner) { viewModelScope.launch { customScannerRepo.add(scanner) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt index 75e86e53..1a8938a9 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt @@ -33,6 +33,8 @@ class WsdScanningProtocol( get() = "WSD" override val usesUrls: Boolean get() = true + override val exampleScannerIdentifierString: String + get() = "http://192.168.178.122/WebServices/ScannerService" override fun createScannerHandle(scannerIdentifier: String): ScannerHandle? { From b4bc8642bd4a8bd7671a1d679e193834d8f51e3c Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 12:36:29 +0200 Subject: [PATCH 17/38] Finish WSD protocol implementation --- .../scanbridge/model/ScanningError.kt | 4 + .../scanbridge/wsd/WsdScanningProtocol.kt | 73 ++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt index 0f5cbd13..af97ecaa 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt @@ -15,6 +15,10 @@ sealed class ScanningError(val unlocalizedMessage: String) { "Job completed but failed. Job status: $jobStatus" ) + data class CannotGetScannerCaps(val error: String) : ScanningError( + "Cannot get scanner capabilities: $error" + ) + data object PollingTimedOut: ScanningError( "Timed out waiting for images to transfer" ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt index 1a8938a9..155d7ac4 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt @@ -1,7 +1,7 @@ package io.github.chrisimx.scanbridge.wsd import io.github.chrisimx.anyscan.CommonScanSettings -import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.ScanningError import io.github.chrisimx.scanbridge.model.UrlScannerHandle @@ -15,7 +15,9 @@ import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import io.github.chrisimx.scanbridge.ports.ScanningProtocol import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler import io.github.chrisimx.wsdkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.wsdkt.anyscancompat.toWSDScanTicket import io.github.chrisimx.wsdkt.client.WsdScanServiceClient +import io.github.chrisimx.wsdkt.scanjob.ScanJob import io.ktor.http.Url import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -106,9 +108,9 @@ class WsdScanningProtocol( val httpConfig = settings.toHttpClientConfig() val httpClient = httpClientFactory.create(httpConfig) - val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + val wsdRequestClient = WsdScanServiceClient(scannerUrlHandle.url, httpClient) - suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { + suspend fun abortIfCancelling(scanJob: ScanJob? = null): Boolean = if (cancelled.value) { _logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } scanJob?.cancel() @@ -120,5 +122,70 @@ class WsdScanningProtocol( if (abortIfCancelling()) return@flow + val caps = wsdRequestClient.retrieveAllScannerElements() + + if (caps !is WsdScanServiceClient.RetrieveAllScannerElementsResult.Success) { + emit( + ScanJobProcessingEvent.Failure( + ScanningError.CannotGetScannerCaps(caps.toString()) + ) + ) + return@flow + } + + val scanTicket = jobScanSettings.toWSDScanTicket( + caps.allScannerElements.scannerConfiguration + ) + + val jobCreationResult = wsdRequestClient.createScanJob(scanTicket) + + if (jobCreationResult !is WsdScanServiceClient.CreateScanJobResult.Success) { + emit( + ScanJobProcessingEvent.Failure( + ScanningError.JobCreationFailed(jobCreationResult.toString()) + ) + ) + return@flow + } + + val job = jobCreationResult.scanJob + + if (abortIfCancelling(job)) return@flow + + while (true) { + if (abortIfCancelling(job)) return@flow + + val newPageResult = job.retrieveNextPage() + when (newPageResult) { + WsdScanServiceClient.RetrieveImageResult.NoFurtherPages -> { + break + } + is WsdScanServiceClient.RetrieveImageResult.Success -> {} + else -> { + job.cancel() + emit(ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(newPageResult.toString(), "null") + )) + return@flow + } + } + + val newPage = newPageResult.page + + if (newPage.contentType == null) { + job.cancel() + emit(ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError("Content type missing", "null") + )) + return@flow + } + + emit(ScanJobProcessingEvent.NewPage( + ScanProtocolScannedPage( + newPage.contentType!!, + newPage.data + ) + )) + } } } From 3fa01243b5025b3bc5ff6e24ae1ccaff0cc126f8 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 12:43:07 +0200 Subject: [PATCH 18/38] Add German translation scan intent strings --- .../src/commonMain/composeResources/values-de/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index 959c5dae..c0c6bd0b 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -126,4 +126,11 @@ Benutzerdefinierten Scanner bearbeiten Willst du den benutzerdefinierten Scanner wirklich löschen? Dieser Vorgang ist unwiderruflich. Benutzerdefinierten Scanner löschen + Dokument + + Foto + Vorschau + 3D-Objekt + Visitenkarte + Protokoll \ No newline at end of file From ab8fbf8e648a6adb2abe7092e1b912b56bf92de6 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 14:56:38 +0200 Subject: [PATCH 19/38] Add TODO note for fixing file name handling in ZIP export --- .../chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 3f485e22..6649f290 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -652,6 +652,7 @@ class ScanningScreenViewModel( zipOutputFile, { counter++ + // TODO: This will never work correctly because extension is always empty "scan-${counter.toString().padStart(digitsNeeded, '0')}.${it.extension}" } ) From e0640a1296abe013dbdb15d8e8cf02ad69e2f312 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 15:26:35 +0200 Subject: [PATCH 20/38] Re-add action button to snackbar in ScanningScreen --- .../chrisimx/scanbridge/ScanningScreen.kt | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index f3ba8b27..adbb15e3 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -54,6 +54,8 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -399,17 +401,35 @@ fun ScanningScreen( contentColor = type.contentColor, shape = RoundedCornerShape(16.dp), action = { - IconButton( - onClick = { data.dismiss() }, - colors = IconButtonColors( - type.containerColor, - type.contentColor, - type.containerColor, - type.contentColor - ), - modifier = Modifier.testTag("snackbar_dismiss") - ) { - Icon(Icons.Default.Close, contentDescription = "Dismiss") + Row { + val actionLabel = visuals?.actionLabel + if (actionLabel != null) { + Button( + onClick = { data.performAction() }, + colors = ButtonColors( + type.containerColor, + type.contentColor, + type.containerColor, + type.contentColor + ), + modifier = Modifier.testTag("snackbar_perform_action") + ) { + Text(actionLabel) + } + } + + IconButton( + onClick = { data.dismiss() }, + colors = IconButtonColors( + type.containerColor, + type.contentColor, + type.containerColor, + type.contentColor + ), + modifier = Modifier.testTag("snackbar_dismiss") + ) { + Icon(Icons.Default.Close, contentDescription = "Dismiss") + } } } ) { From dbaf0ea1b3b008d3aefddc13d89027452bd9600b Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 15:27:14 +0200 Subject: [PATCH 21/38] Fix pdf export functionality --- .../data/ui/ScanningScreenViewModel.kt | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 6649f290..e6ad3300 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -42,12 +42,9 @@ import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.anyscan.FileFormat import io.github.chrisimx.anyscan.ScanSettingsMap +import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.inches -import io.github.chrisimx.anyscan.millimeters -import io.github.chrisimx.anyscan.threeHundredthsOfInch import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.ScanRegion import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService import io.github.chrisimx.scanbridge.datastore.appSettingsStore @@ -470,7 +467,7 @@ class ScanningScreenViewModel( onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null ) { - /*val currentScans = scannedPages.value + val currentScans = scannedPages.value val scannerCapsNullable = scanningScreenData.capabilities val scannerCaps = if (scannerCapsNullable == null) { onError(application.getString(R.string.scannercapabilities_null)) @@ -520,23 +517,22 @@ class ScanningScreenViewModel( 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 fallbackInputSourceCaps = scannerCaps.inputSources.first() + + val fallbackResolution = fallbackInputSourceCaps + .furtherOptions[ScannerConcept.ScanResolution]!! + .defaultValue - val fallbackResolution = scannerCaps.getMaxResolution(inputSource) - val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution - val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution + val originalSettingsMap = scan.originalScanSettings.setting + + val scanResolution = originalSettingsMap[ScannerConcept.ScanResolution] ?: fallbackResolution + + val scannerXResolution = scanResolution.value.widthDPI + val scannerYResolution = scanResolution.value.heightDPI val rotationCorrectedXRes = if (rotated) scannerYResolution else scannerXResolution val rotationCorrectedYRes = if (rotated) scannerXResolution else scannerYResolution @@ -602,7 +598,7 @@ class ScanningScreenViewModel( } else { setFileToSave(outputFile) saveFileLauncher.launch(outputFile.name) - }*/ + } } fun doZipExport(context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null) { From 3469339f6a9be94799ac0f1b179c2fb6b4a40448 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 19 Jul 2026 15:48:35 +0200 Subject: [PATCH 22/38] Make prerelease --- androidApp/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index aa409b6a..be1b92a6 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -41,8 +41,8 @@ android { applicationId = "io.github.chrisimx.scanbridge" minSdk = 28 targetSdk = 36 - versionCode = 2_001_004 // format is MAJ_MIN_PAT with always 3 digits - versionName = "2.1.4" + versionCode = 2_001_005 // we just count up for each build + versionName = "2.2.0-alpha1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["escl_server_url"] = From 6a0a264e4a56f0ef991f3e02d498bcd9deaaebf0 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Mon, 20 Jul 2026 00:27:25 +0200 Subject: [PATCH 23/38] Introduce initial scan settings provider and use it --- .../DefaultInitialScanSettingsProvider.kt | 14 ++++++++++++++ .../chrisimx/scanbridge/ScanBridgeApplication.kt | 2 ++ .../scanbridge/data/ui/ScanningScreenViewModel.kt | 10 +++------- .../ports/InitialScanSettingsProvider.kt | 8 ++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/DefaultInitialScanSettingsProvider.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/DefaultInitialScanSettingsProvider.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/DefaultInitialScanSettingsProvider.kt new file mode 100644 index 00000000..552b7651 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/DefaultInitialScanSettingsProvider.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge + +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.FileFormat +import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw +import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider + +class DefaultInitialScanSettingsProvider : InitialScanSettingsProvider { + override fun applyDefaults(editor: CommonScanSettingsEditor, capabilities: CommonScannerCapabilities) { + editor.setInputSource(capabilities.inputSources.first().inputSourceType) + editor.setFormat(AnyScanEnumOrRaw.Known(FileFormat.JPEG)) + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index fd093100..83957aec 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -30,6 +30,7 @@ import io.github.chrisimx.scanbridge.ports.HttpClientFactory import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.ScanningProtocol import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler @@ -118,6 +119,7 @@ val appModule = module { DatastoreShownMessagesRepository(get(named()), scope) } bind ShownMessagesRepository::class single() bind ScanningProtocolManager::class + single() bind InitialScanSettingsProvider::class factory() viewModel() single() bind CustomScannerRepository::class diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index e6ad3300..6892da37 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -40,11 +40,9 @@ import com.itextpdf.layout.element.Image import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.CommonScanSettingsEditor import io.github.chrisimx.anyscan.CommonScannerCapabilities -import io.github.chrisimx.anyscan.FileFormat import io.github.chrisimx.anyscan.ScanSettingsMap import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.inches -import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService import io.github.chrisimx.scanbridge.datastore.appSettingsStore @@ -58,6 +56,7 @@ import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.scannerCapabilities import io.github.chrisimx.scanbridge.model.toggleRotation +import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull @@ -112,6 +111,7 @@ class ScanningScreenViewModel( val db: ScanBridgeDb, application: Application, val scanJobRepo: ScanJobRepository, + val initialScanSettingsProvider: InitialScanSettingsProvider ) : AndroidViewModel(application) { private val _scanningScreenData = ScanningScreenData( @@ -345,15 +345,11 @@ class ScanningScreenViewModel( ) editor.build() } else { - // TODO: Use actual defaults here and use swappable default providers val editor = CommonScanSettingsEditor( caps, CommonScanSettings(setting = ScanSettingsMap.empty()) ) - - editor.setInputSource(caps.inputSources.first().inputSourceType) - editor.setFormat(AnyScanEnumOrRaw.Known(FileFormat.JPEG)) - + initialScanSettingsProvider.applyDefaults(editor, caps) editor.build() } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt new file mode 100644 index 00000000..98a47d93 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.ports + +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities + +interface InitialScanSettingsProvider { + fun applyDefaults(editor: CommonScanSettingsEditor, capabilities: CommonScannerCapabilities) +} From b063e720361414abdd82ee3571e7548727372348 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Mon, 20 Jul 2026 13:58:34 +0200 Subject: [PATCH 24/38] Implement scan region parameter display --- .../scanbridge/ScanBridgeApplication.kt | 1 + .../chrisimx/scanbridge/ScanSettings.kt | 108 ++++++++++++- .../chrisimx/scanbridge/ScanningScreen.kt | 4 +- .../data/model/LegacyESCLScanSettings.kt | 8 +- .../ui/ScanSettingsComposableStateHolder.kt | 150 ++++++++++++++++-- .../data/ui/ScanningScreenViewModel.kt | 12 +- .../dialog/CustomScannerDialog.kt | 1 + .../scanbridge/util/ESCLKtExtensions.kt | 14 +- composeUI/build.gradle.kts | 1 + .../uicomponents/ValidatedTextField.kt | 21 +-- .../scanbridge/PaperFormatProvider.kt | 13 ++ .../model/NumberValidationResult.kt | 3 +- .../model/ScanSettingsEnterableData.kt | 2 - 13 files changed, 291 insertions(+), 47 deletions(-) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt (74%) create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 83957aec..3d0746df 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -120,6 +120,7 @@ val appModule = module { } bind ShownMessagesRepository::class single() bind ScanningProtocolManager::class single() bind InitialScanSettingsProvider::class + single() bind PaperFormatProvider::class factory() viewModel() single() bind CustomScannerRepository::class diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index bcd115a8..4beb638a 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -19,11 +19,12 @@ package io.github.chrisimx.scanbridge -import android.content.Context +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -41,12 +42,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder @@ -54,6 +55,7 @@ import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit import io.github.chrisimx.scanbridge.uicomponents.SelectionButtonRow import io.github.chrisimx.scanbridge.uicomponents.SelectionCard import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView +import io.github.chrisimx.scanbridge.uicomponents.ValidatedDimensionsTextEdit import io.github.chrisimx.scanbridge.util.UIInputSourceType import io.github.chrisimx.scanbridge.util.toLocalizedName import io.github.chrisimx.scanbridge.util.toReadableString @@ -61,7 +63,6 @@ import io.github.chrisimx.scanbridge.util.toReadableString @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComposableStateHolder) { - val context = LocalContext.current val vmData by scanSettingsStateHolder.uiState.collectAsState() val duplexCurrentlyAvailable by scanSettingsStateHolder.duplexSettingAvailable.collectAsState() @@ -96,7 +97,6 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp inputSourceOptions, scanSettingsStateHolder, selectedInputSource, - context, duplexCurrentlyAvailable, duplexUsed ) @@ -115,12 +115,107 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp is ScanSettingParam.ScanSettingDoubleParam -> TODO() is ScanSettingParam.ScanSettingFloatParam -> TODO() is ScanSettingParam.ScanSettingIntParam -> TODO() - is ScanSettingParam.ScanSettingRegionParam -> {} + is ScanSettingParam.ScanSettingRegionParam -> { + RegionParameterDisplay( + scannerConceptLocalizedName, + scanSettingsStateHolder, + scanSettings, + userUnitString, + ) + } + } + } + } +} + +@Composable +private fun RegionParameterDisplay( + scannerConceptLocalizedName: String, + scanSettingsStateHolder: ScanSettingsComposableStateHolder, + scanSettings: CommonScanSettings, + userUnitString: String, +) { + val vmData by scanSettingsStateHolder.uiState.collectAsState() + val availablePaperFormats by scanSettingsStateHolder.availablePaperFormats.collectAsState() + val currentScanRegion = scanSettings.setting[ScannerConcept.ScanRegion] + + val widthValidationResult by scanSettingsStateHolder.validationResultWidth.collectAsState() + val heightValidationResult by scanSettingsStateHolder.validationResultHeight.collectAsState() + + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 15.dp, bottom = 15.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + scannerConceptLocalizedName, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + FlowRow( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + availablePaperFormats.forEach { paperFormat -> + InputChip( + onClick = { + scanSettingsStateHolder.setFormat(paperFormat) + }, + label = { Text(paperFormat.name) }, + selected = !vmData.customMenuEnabled && !vmData.maximumSize && + currentScanRegion?.value?.width?.equalsLength(paperFormat.width) == true && + currentScanRegion?.value?.height?.equalsLength(paperFormat.height) == true + ) + } + InputChip( + onClick = { + scanSettingsStateHolder.selectMaxRegion() + }, + label = { Text(stringResource(R.string.maximum_size)) }, + selected = + vmData.maximumSize && !vmData.customMenuEnabled + ) + InputChip( + selected = vmData.customMenuEnabled, + onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, + label = { Text(stringResource(R.string.custom)) } + ) + } + AnimatedVisibility(vmData.customMenuEnabled) { + Row(horizontalArrangement = Arrangement.SpaceEvenly) { + ValidatedDimensionsTextEdit( + vmData.widthString, + modifier = Modifier + .weight(1f) + .padding(end = 10.dp), + stringResource(R.string.width_in_unit, userUnitString), + { newText: String -> + scanSettingsStateHolder.setCustomWidthTextFieldContent( + newText + ) + }, + widthValidationResult + ) + ValidatedDimensionsTextEdit( + vmData.heightString, + modifier = Modifier + .weight(1f) + .padding(start = 10.dp), + stringResource(R.string.height_in_unit, userUnitString), + { scanSettingsStateHolder.setCustomHeightTextFieldContent(it) }, + heightValidationResult + ) + } } } } } +private fun LengthUnit.equalsLength(other: LengthUnit): Boolean = + this.toMillimeters().value == other.toMillimeters().value + @Composable private fun ChoiceParameterDisplay( scannerConceptLocalizedName: String, @@ -161,7 +256,6 @@ private fun InputSourceSelection( inputSourceOptions: List, scanSettingsStateHolder: ScanSettingsComposableStateHolder, selectedInputSource: UIInputSourceType, - context: Context, duplexCurrentlyAvailable: Boolean, duplexUsed: Boolean ) { @@ -183,7 +277,7 @@ private fun InputSourceSelection( onClick = { scanSettingsStateHolder.setInputSource(inputSource) }, selected = selectedInputSource == inputSource ) { - Text(inputSource.toReadableString(context)) + Text(inputSource.toReadableString()) } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index adbb15e3..3c580b8c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -672,9 +672,7 @@ fun ScanContent( if (currentPages.size > pagerState.currentPage) { Text( - currentPage?.originalScanSettings?.inputSource?.toUIInputSourceType()?.toReadableString( - context - ).toString() + currentPage?.originalScanSettings?.inputSource?.toUIInputSourceType()?.toReadableString().toString() ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt index 5ace762d..f4e1165b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -28,18 +28,18 @@ data class StatelessImmutableScanRegion( fun toESCLScanRegion(selectedInputSourceCaps: InputSourceCaps): ScanRegion { val height: LengthUnit = when (height) { "max" -> selectedInputSourceCaps.maxHeight - else -> height.toDoubleLocalized().millimeters() + else -> height.toDoubleLocalized()!!.millimeters() } val width: LengthUnit = when (width) { "max" -> selectedInputSourceCaps.maxWidth - else -> width.toDoubleLocalized().millimeters() + else -> width.toDoubleLocalized()!!.millimeters() } return ScanRegion( height.toThreeHundredthsOfInch(), width.toThreeHundredthsOfInch(), - xOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch(), - yOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch() + xOffset.toDoubleLocalized()!!.millimeters().toThreeHundredthsOfInch(), + yOffset.toDoubleLocalized()!!.millimeters().toThreeHundredthsOfInch() ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 8bed991f..6e1595be 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -19,19 +19,29 @@ package io.github.chrisimx.scanbridge.data.ui -import android.app.Application +import com.google.protobuf.LazyStringArrayList.emptyList +import io.github.chrisimx.anyscan.Area import io.github.chrisimx.anyscan.CommonInputSourceCaps import io.github.chrisimx.anyscan.CommonInputSourceType import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.ScanRegionValue +import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.SettingValue +import io.github.chrisimx.anyscan.inches +import io.github.chrisimx.anyscan.millimeters +import io.github.chrisimx.scanbridge.PaperFormat +import io.github.chrisimx.scanbridge.PaperFormatProvider import io.github.chrisimx.scanbridge.model.Locale +import io.github.chrisimx.scanbridge.model.NumberValidationResult import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.util.UIInputSourceType import io.github.chrisimx.scanbridge.util.derived +import io.github.chrisimx.scanbridge.util.toDoubleLocalized import io.github.chrisimx.scanbridge.util.toUIInputSourceType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -39,7 +49,14 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam import timber.log.Timber @@ -50,6 +67,8 @@ enum class ScanSettingsLengthUnit { } class ScanSettingsComposableStateHolder( + @InjectedParam + val capabilities: StateFlow, @InjectedParam val scanSettings: StateFlow, @InjectedParam @@ -59,14 +78,14 @@ class ScanSettingsComposableStateHolder( @InjectedParam private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, - private val context: Application + private val paperFormatProvider: PaperFormatProvider, ) { private val _uiState = MutableStateFlow(initialScanSettingsData) val uiState: StateFlow = _uiState.asStateFlow() - val inputSourceOptions: StateFlow> = _uiState.derived(coroutineScope) { - it.capabilities.inputSources.map { + val inputSourceOptions: StateFlow> = capabilities.derived(coroutineScope) { caps -> + caps.inputSources.map { it.inputSourceType.toUIInputSourceType() }.distinct() } @@ -79,8 +98,8 @@ class ScanSettingsComposableStateHolder( it.inputSource == CommonInputSourceType.ADF_DUPLEX } - val duplexAdfSupported: StateFlow = _uiState.derived(coroutineScope) { - it.capabilities.inputSources.firstOrNull { + val duplexAdfSupported: StateFlow = capabilities.derived(coroutineScope) { caps -> + caps.inputSources.firstOrNull { it.inputSourceType == CommonInputSourceType.ADF_DUPLEX } != null } @@ -90,20 +109,77 @@ class ScanSettingsComposableStateHolder( (scanSettings.inputSource in setOf(CommonInputSourceType.ADF_DUPLEX, CommonInputSourceType.ADF_SIMPLEX)) }.stateIn(coroutineScope, SharingStarted.Lazily, false) - private val selectedInputSourceCaps: StateFlow = combine(scanSettings, _uiState) { settings, uiState -> - uiState.capabilities.inputSources.first { + private val selectedInputSourceCaps: StateFlow = combine(scanSettings, capabilities) { settings, caps -> + caps.inputSources.first { it.inputSourceType == settings.inputSource } }.stateIn( coroutineScope, SharingStarted.Lazily, - uiState.value.capabilities.inputSources.first() + capabilities.value.inputSources.first() ) + private fun validateDimensionValue( + valueString: String, + getDimension: (Area) -> LengthUnit + ): NumberValidationResult { + if (valueString.isBlank()) { + return NumberValidationResult.NotANumber + } + + val dimension = valueString.toDoubleLocalized() ?: return NumberValidationResult.NotANumber + + val regionParam = capabilities.value.inputSources.firstOrNull { + it.inputSourceType == scanSettings.value.inputSource + }?.furtherOptions?.get(ScannerConcept.ScanRegion) as? ScanSettingParam.ScanSettingRegionParam + + val lengthInUnit = when (lengthUnit.value) { + ScanSettingsLengthUnit.INCH -> dimension.inches() + ScanSettingsLengthUnit.MILLIMETER -> dimension.millimeters() + } + + if (regionParam == null) { + return NumberValidationResult.Success(lengthInUnit) + } + + val maxDimension = toUserUnit(lengthUnit.value, getDimension(regionParam.maxArea.value)) + val minDimension = toUserUnit(lengthUnit.value, getDimension(regionParam.minArea.value)) + + if (dimension !in minDimension..maxDimension) { + return NumberValidationResult.OutOfRange(minDimension, maxDimension) + } else { + return NumberValidationResult.Success(lengthInUnit) + } + } + + val validationResultHeight: StateFlow = combine(uiState, capabilities) { settings, caps -> + validateDimensionValue(settings.heightString) { it.height } + }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) + + val validationResultWidth: StateFlow = combine(uiState, capabilities) { settings, caps -> + validateDimensionValue(settings.widthString) { it.width } + }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) + val availableParameters = selectedInputSourceCaps.derived(coroutineScope) { it.furtherOptions } + val availablePaperFormats: StateFlow> = combine( + selectedInputSourceCaps, + paperFormatProvider.formats + ) { inputSourceCaps, paperFormats -> + val regionParam = inputSourceCaps.furtherOptions[ScannerConcept.ScanRegion] as? ScanSettingParam.ScanSettingRegionParam + if (regionParam == null) { + emptyList() + } else { + val maxArea = regionParam.maxArea.value + paperFormats.filter { paperFormat -> + paperFormat.width.toMillimeters().value <= maxArea.width.toMillimeters().value + 0.1 && + paperFormat.height.toMillimeters().value <= maxArea.height.toMillimeters().value + 0.1 + } + } + }.stateIn(coroutineScope, SharingStarted.Lazily, emptyList()) + val lengthUnit = localeProvider.locale.derived(coroutineScope) { unitByLocale(it) } @@ -120,7 +196,23 @@ class ScanSettingsComposableStateHolder( } init { - /*_uiState + combine(validationResultHeight, validationResultWidth, _uiState) { height, width, ui -> + Triple(height, width, ui.customMenuEnabled) + }.mapNotNull { (height, width, customMenuEnabled) -> + if (customMenuEnabled && height is NumberValidationResult.Success && width is NumberValidationResult.Success) { + height to width + } else { + null + } + }.onEach { (height, width) -> + updateSettings { + this.set(ScannerConcept.ScanRegion, ScanRegionValue( + Area(height.value, width.value) + )) + } + }.launchIn(coroutineScope) + + _uiState .map { it.maximumSize } .distinctUntilChanged() .combine(selectedInputSourceCaps) { maxSize, inputSourceCaps -> Pair(maxSize, inputSourceCaps) } @@ -134,7 +226,7 @@ class ScanSettingsComposableStateHolder( set(ScannerConcept.ScanRegion, maxArea) } } - }.launchIn(coroutineScope)*/ + }.launchIn(coroutineScope) } fun setDuplex(duplex: Boolean) { @@ -185,4 +277,40 @@ class ScanSettingsComposableStateHolder( } } } + + fun setCustomMenuEnabled(enabled: Boolean) { + _uiState.update { it.copy(customMenuEnabled = enabled) } + } + + fun setFormat(paperFormat: PaperFormat) { + val area = Area( + height = paperFormat.height, + width = paperFormat.width + ) + _uiState.update { it.copy(maximumSize = false, customMenuEnabled = false) } + + coroutineScope.launch { + updateSettings { + set(ScannerConcept.ScanRegion, ScanRegionValue(area)) + } + } + } + + fun setCustomWidthTextFieldContent(content: String) { + _uiState.update { + it.copy(widthString = content) + } + } + + fun setCustomHeightTextFieldContent(content: String) { + _uiState.update { + it.copy(heightString = content) + } + } + + fun selectMaxRegion() { + _uiState.update { + it.copy(maximumSize = true, customMenuEnabled = false) + } + } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 6892da37..4ba38f35 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -75,8 +75,10 @@ import kotlin.io.path.Path import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -320,16 +322,15 @@ class ScanningScreenViewModel( } } - val defaultScanSettingsUIData = ScanSettingsEnterableDataV1( - caps - ) + val defaultScanSettingsUIData = ScanSettingsEnterableDataV1() if (storedSession != null) { _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( + MutableStateFlow(caps).asStateFlow(), session.map { it?.currentScanSettings ?: storedSession.currentScanSettings } .stateIn(viewModelScope, SharingStarted.Lazily, storedSession.currentScanSettings), - storedSession.currentSettingsUIData?.copy(capabilities = caps) ?: defaultScanSettingsUIData, + storedSession.currentSettingsUIData?.copy() ?: defaultScanSettingsUIData, updateSettings, viewModelScope ) @@ -353,12 +354,13 @@ class ScanningScreenViewModel( editor.build() } - val savedSettingsUiStateWithCaps = savedSettingsUiState?.copy(capabilities = caps) + val savedSettingsUiStateWithCaps = savedSettingsUiState?.copy() sessionDao.insertAll(Session(sessionID, initialSettings, savedSettingsUiState)) _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( + MutableStateFlow(caps).asStateFlow(), session.map { it?.currentScanSettings ?: initialSettings } .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), savedSettingsUiStateWithCaps ?: defaultScanSettingsUIData, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index ed82c024..1313f2c9 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt @@ -65,6 +65,7 @@ fun CustomScannerDialog( val isNewScanner = editingType is EditedCustomScanner.New + // TODO: Move validation to view model val validateUrl = fun(): Url? { if (urlText.isEmpty()) { urlErrorState = context.getString(R.string.error_state_please_enter_an_url) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 6a027dac..e6fc7c7a 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -21,6 +21,8 @@ package io.github.chrisimx.scanbridge.util import android.content.Context import android.icu.text.DecimalFormat +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.scanbridge.R @@ -33,14 +35,18 @@ fun JobState?.toJobStateString(context: Context): String = when (this) { null -> context.getString(R.string.job_state_cannot_be_retrieved) } -fun String.toDoubleLocalized(): Double = DecimalFormat.getInstance().parse(this).toDouble() +fun String.toDoubleLocalized(): Double? = runCatching { + DecimalFormat.getInstance().parse(this).toDouble() +}.getOrNull() fun Double.toStringLocalized(): String = DecimalFormat.getInstance().format(this) -fun UIInputSourceType.toReadableString(context: Context): String = when (this) { +@Composable +fun UIInputSourceType.toReadableString(): String = when (this) { UIInputSourceType.PLATEN -> - context.getString(R.string.platen) - UIInputSourceType.ADF -> context.getString(R.string.adf) + stringResource(R.string.platen) + + UIInputSourceType.ADF -> stringResource(R.string.adf) } /** diff --git a/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index 2a5e5a42..f7881737 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { api(libs.compose.material3) api(libs.anyscanCore) + api(project(":core")) } commonTest.dependencies { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt similarity index 74% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt index 89a869ca..06f26cdd 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt @@ -19,7 +19,6 @@ package io.github.chrisimx.scanbridge.uicomponents -import android.content.Context import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -27,19 +26,23 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType -import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.model.NumberValidationResult +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.error_state_not_a_valid_number +import scanbridge.composeui.generated.resources.error_state_not_in_allowed_range +import scanbridge.composeui.generated.resources.error_state_valid -fun NumberValidationResult.toHumanString(context: Context): String = when (this) { - is NumberValidationResult.OutOfRange -> context.getString(R.string.error_state_not_in_allowed_range) - NumberValidationResult.NotANumber -> context.getString(R.string.error_state_not_a_valid_number) - is NumberValidationResult.Success -> context.getString(R.string.error_state_valid) +@Composable +fun NumberValidationResult.toHumanString(): String = when (this) { + is NumberValidationResult.OutOfRange -> stringResource(Res.string.error_state_not_in_allowed_range) + NumberValidationResult.NotANumber -> stringResource(Res.string.error_state_not_a_valid_number) + is NumberValidationResult.Success -> stringResource(Res.string.error_state_valid) } @Composable fun ValidatedDimensionsTextEdit( text: String, - context: Context, modifier: Modifier = Modifier, label: String, updateContent: (String) -> Unit, @@ -54,9 +57,7 @@ fun ValidatedDimensionsTextEdit( supportingText = { if (validationResult !is NumberValidationResult.Success) { Text( - validationResult.toHumanString( - context - ), + validationResult.toHumanString(), style = MaterialTheme.typography.labelSmall ) } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt new file mode 100644 index 00000000..c01a041d --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt @@ -0,0 +1,13 @@ +package io.github.chrisimx.scanbridge + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +interface PaperFormatProvider { + val formats: StateFlow> +} + +class DefaultPaperFormatProvider : PaperFormatProvider { + override val formats: StateFlow> = MutableStateFlow(loadDefaultFormats()).asStateFlow() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt index d8440620..e0e764c4 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt @@ -1,10 +1,11 @@ package io.github.chrisimx.scanbridge.model +import io.github.chrisimx.anyscan.LengthUnit import kotlinx.serialization.Serializable @Serializable sealed class NumberValidationResult { - data class Success(val value: Double) : NumberValidationResult() + data class Success(val value: LengthUnit) : NumberValidationResult() data class OutOfRange(val min: Double, val max: Double) : NumberValidationResult() data object NotANumber : NumberValidationResult() } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt index 115eb73f..8ff81c67 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt @@ -19,8 +19,6 @@ data class ScanSettingsEnterableDataV0( @Serializable data class ScanSettingsEnterableDataV1( - val capabilities: CommonScannerCapabilities, - val paperFormats: List = loadDefaultFormats(), val customMenuEnabled: Boolean = false, val widthString: String = "", val heightString: String = "", From a2260cf46e95b1f6e3a968422036252e04b37ce7 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 25 Jul 2026 09:31:35 +0200 Subject: [PATCH 25/38] Faster WSD discovery by running flow emission and better lifecycle management for WSD discovery --- .../chrisimx/scanbridge/ScannerBrowser.kt | 7 +-- .../ports/ScannerDiscoveryBackend.kt | 3 +- .../ScannerDiscoveryScreenViewModel.kt | 2 +- .../wsd/WsdScannerDiscoveryBackend.kt | 43 +++++++++---------- 4 files changed, 26 insertions(+), 29 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index 14f3bd82..faf6c618 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -41,23 +41,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.github.chrisimx.scanbridge.data.model.EditedCustomScanner import io.github.chrisimx.scanbridge.db.entities.CustomScanner -import io.github.chrisimx.scanbridge.escl.EsclScanningProtocol import io.github.chrisimx.scanbridge.model.DiscoveredScanner -import io.github.chrisimx.scanbridge.model.UrlScannerHandle import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel import io.github.chrisimx.scanbridge.uicomponents.FoundScannerItem import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.dialog.CustomScannerDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.DeletionDialog -import io.ktor.http.Url import java.util.* import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import org.koin.androidx.compose.koinViewModel -import org.koin.compose.getKoin @Composable fun ScannerList( @@ -146,7 +143,7 @@ fun ScannerBrowser( ) { val scannerDiscoveryScreenViewModel: ScannerDiscoveryScreenViewModel = koinViewModel() val customScanners by scannerDiscoveryScreenViewModel.customScanners.collectAsState() - val discoveredScanners by scannerDiscoveryScreenViewModel.discoveredScanners.collectAsState() + val discoveredScanners by scannerDiscoveryScreenViewModel.discoveredScanners.collectAsStateWithLifecycle() val protocolsForCustomScanners = scannerDiscoveryScreenViewModel.protocolsForCustomScanners var deletionScheduledScanner: Uuid? by remember { mutableStateOf(null) } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt index 148a5b61..01ebda7e 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt @@ -2,8 +2,9 @@ package io.github.chrisimx.scanbridge.ports import io.github.chrisimx.scanbridge.model.DiscoveredScanner import io.github.chrisimx.scanbridge.model.ScannerHandle +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface ScannerDiscoveryBackend : AutoCloseable { - val scanners: StateFlow> + val scanners: Flow> } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt index e4f48c72..0f54ce05 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt @@ -27,7 +27,7 @@ class ScannerDiscoveryScreenViewModel( val customScanners: StateFlow> = _customScanners .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val discoveredScanners = discoveryUsecase.discoveredScanners(viewModelScope.coroutineScope) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) /** * The protocols for which we can create a custom scanner. diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt index ed6b3492..0985cce9 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt @@ -6,14 +6,13 @@ import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler import io.github.chrisimx.wsdkt.wsdiscovery.WsScannerServiceDiscovery -import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam @@ -38,8 +37,6 @@ class WsdScannerDiscoveryBackend( debugLog = ::discoveryDebugLog ) - private val closed = AtomicBoolean(false) - init { multicastLockHandler.acquire() @@ -51,22 +48,24 @@ class WsdScannerDiscoveryBackend( } } } - - override fun close() { - if (closed.compareAndSet(expectedValue = false, newValue = true)) { - multicastLockHandler.release() - } - } - override val scanners: StateFlow> - get() = wsScannerServiceDiscovery.discoveredDevices.map { scannerList -> - scannerList.map { scannerService -> - DiscoveredScanner( - name = scannerService.name, - handle = UrlScannerHandle(protocol, scannerService.url), - scannerCaps = null, - iconUrl = null - ) + override fun close() {} + + override val scanners: Flow> = + wsScannerServiceDiscovery.discoveredDevices + .onStart { + multicastLockHandler.acquire() + } + .onCompletion { cause -> + multicastLockHandler.release() + }.map { scannerList -> + scannerList.map { scannerService -> + DiscoveredScanner( + name = scannerService.name, + handle = UrlScannerHandle(protocol, scannerService.url), + scannerCaps = null, + iconUrl = null + ) + } } - }.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) } From e44f9ba505ef4b2ef408d01c7070b79e6ef7237c Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 25 Jul 2026 09:31:52 +0200 Subject: [PATCH 26/38] Tolerance for scan region selection --- .../data/ui/ScanSettingsComposableStateHolder.kt | 13 +++++++++---- .../io/github/chrisimx/scanbridge/PaperFormat.kt | 11 ++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 6e1595be..6dbefa1c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -173,10 +173,15 @@ class ScanSettingsComposableStateHolder( emptyList() } else { val maxArea = regionParam.maxArea.value - paperFormats.filter { paperFormat -> - paperFormat.width.toMillimeters().value <= maxArea.width.toMillimeters().value + 0.1 && - paperFormat.height.toMillimeters().value <= maxArea.height.toMillimeters().value + 0.1 - } + val minArea = regionParam.minArea.value + + val maxAreaWithTol = maxArea + 0.1.millimeters() + val minAreaWithTol = minArea - 0.1.millimeters() + + paperFormats + .filter { paperFormat -> + paperFormat.area in maxAreaWithTol && minAreaWithTol in paperFormat.area + } } }.stateIn(coroutineScope, SharingStarted.Lazily, emptyList()) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt index 86747942..3eccb5d1 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt @@ -1,12 +1,21 @@ package io.github.chrisimx.scanbridge +import io.github.chrisimx.anyscan.Area import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.Millimeters import io.github.chrisimx.anyscan.millimeters import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient @Serializable -data class PaperFormat(val name: String, val width: LengthUnit, val height: LengthUnit) +data class PaperFormat( + val name: String, + val width: LengthUnit, + val height: LengthUnit +) { + @Transient + val area = Area(height, width) +} fun loadDefaultFormats(): List { val defaultPaperFormats: MutableList = mutableListOf() From 1b60ac21ec85185098ee99e636905576cc6300e6 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 29 Jul 2026 20:39:41 +0200 Subject: [PATCH 27/38] Add migration for Room database to handle type changes --- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 11 +- .../scanbridge/datastore/DataStores.kt | 2 - .../AppSettingsMigrationV0To1.kt | 82 ------------ .../5.json | 9 +- .../scanbridge/db/ScanBridgeDbFactory.kt | 2 + .../scanbridge/db/entities/ScannedPage.kt | 1 - .../scanbridge/db/migrations/Version4To5.kt | 117 ++++++++++++++++++ .../model/ScanSettingsEnterableData.kt | 9 +- settings.gradle.kts | 8 -- 9 files changed, 141 insertions(+), 100 deletions(-) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index a3518092..dfc5aee5 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -37,9 +37,11 @@ import io.github.chrisimx.scanbridge.util.doTempFilesExist import kotlin.uuid.Uuid import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNames import org.koin.compose.koinInject import timber.log.Timber @@ -52,7 +54,14 @@ object StartUpScreenRoute : BaseRoute @Serializable @SerialName("ScannerRoute") -data class ScannerRoute(val scannerName: String, val scannerHandleString: String, val protocolId: String, val sessionID: String) : BaseRoute +@OptIn(ExperimentalSerializationApi::class) +data class ScannerRoute( + val scannerName: String, + @JsonNames("scannerHandle", "scannerURL") + val scannerHandleString: String, + val protocolId: String = "eSCL", + val sessionID: String +) : BaseRoute @Serializable @SerialName("CropImageRoute") diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt index 44284492..0a93e0ba 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt @@ -7,7 +7,6 @@ import androidx.datastore.migrations.SharedPreferencesMigration import com.google.protobuf.BoolValue import com.google.protobuf.StringValue import com.google.protobuf.UInt32Value -import io.github.chrisimx.scanbridge.migrations.appsettingsstore.AppSettingsMigrationV0To1 import io.github.chrisimx.scanbridge.proto.LastRoute import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages @@ -34,7 +33,6 @@ val Context.appSettingsStore: DataStore by dataStore( rememberScanSettings = BoolValue.of(sharedPrefs.getBoolean("remember_scan_settings", true)) }.build() }, - AppSettingsMigrationV0To1 ) } ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt deleted file mode 100644 index 1e56d564..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt +++ /dev/null @@ -1,82 +0,0 @@ -package io.github.chrisimx.scanbridge.migrations.appsettingsstore - -import androidx.datastore.core.DataMigration -import com.google.protobuf.StringValue -import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.esclkt.anyscancompat.CommonAbstractionConversionResult -import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction -import io.github.chrisimx.scanbridge.ScanSettingsJson -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 -import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings -import io.github.chrisimx.scanbridge.proto.copy -import io.github.chrisimx.scanbridge.proto.lastUsedScanSettingsUiStateOrNull -import timber.log.Timber - -// V0 to V1, change type of serialized ScanSettings and ScanSettingsEnterableData -object AppSettingsMigrationV0To1 : DataMigration { - override suspend fun shouldMigrate(currentData: ScanBridgeSettings): Boolean { - return currentData.appSettingsVersion == 0 - } - - override suspend fun migrate(currentData: ScanBridgeSettings): ScanBridgeSettings { - /* - val json = ScanSettingsJson.json - val oldScanSettingsString = currentData.lastUsedScanSettings?.value - val oldScanSettingsParsed = oldScanSettingsString?.let { - json.decodeFromString(it) - } - - val oldEnterableData = currentData.lastUsedScanSettingsUiStateOrNull?.value - val oldEnterableDataParsed = oldEnterableData?.let { - json.decodeFromString(it) - } - - val newScanSettings = oldScanSettingsParsed?.let { - json.encodeToString(oldScanSettingsParsed.toCommonAbstraction()) - } - - val newEnterableData = oldEnterableDataParsed?.let { - val commonCapsResult = it.capabilities.toCommonAbstraction() - - val commonCaps = when (commonCapsResult) { - is CommonAbstractionConversionResult.Success -> { - commonCapsResult.value - } - - is CommonAbstractionConversionResult.Failure -> { - Timber.e("Could not convert enterable data in Migration of LastUsedScanSettings V0 to V1") - return@let null - } - } - - json.encodeToString( - ScanSettingsEnterableDataV1( - commonCaps, - it.paperFormats, - it.customMenuEnabled, - it.widthString, - it.heightString, - it.maximumSize - ) - ) - } - - - return currentData.copy { - appSettingsVersion = 1 - if (newScanSettings != null) { - lastUsedScanSettings = StringValue.of(newScanSettings) - } - if (newEnterableData != null) { - lastUsedScanSettingsUiState = StringValue.of(newEnterableData) - } - }*/ - - return currentData - } - - override suspend fun cleanUp() { - return - } -} diff --git a/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json index e4690b49..c51159db 100644 --- a/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json +++ b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 5, - "identityHash": "967a7f604f397eeb0b79f656da762e6d", + "identityHash": "057d1a9308ba437a28a881e7a441cfbf", "entities": [ { "tableName": "customscanners", @@ -43,7 +43,7 @@ }, { "tableName": "scannedpages", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scanId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `filePath` TEXT NOT NULL, `originalScanSettings` TEXT NOT NULL, `rotation` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `outputName` TEXT DEFAULT null, PRIMARY KEY(`scanId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scanId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `filePath` TEXT NOT NULL, `originalScanSettings` TEXT NOT NULL, `rotation` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `outputName` TEXT, PRIMARY KEY(`scanId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "scanId", @@ -84,8 +84,7 @@ { "fieldPath": "outputName", "columnName": "outputName", - "affinity": "TEXT", - "defaultValue": "null" + "affinity": "TEXT" } ], "primaryKey": { @@ -263,7 +262,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '967a7f604f397eeb0b79f656da762e6d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '057d1a9308ba437a28a881e7a441cfbf')" ] } } \ No newline at end of file diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt index 5980bea5..d12fc57d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt @@ -1,5 +1,6 @@ package io.github.chrisimx.scanbridge.db +import MIGRATION_4_5 import androidx.sqlite.driver.bundled.BundledSQLiteDriver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -14,6 +15,7 @@ class DefaultScanBridgeDbFactory(val builderFactory: ScanBridgeDbBuilderFactory) return dbBuilder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .addMigrations(MIGRATION_4_5) .build() } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt index f67b3b47..e09c5bfc 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt @@ -35,6 +35,5 @@ data class ScannedPage( val originalScanSettings: CommonScanSettings, val rotation: ScanRelativeRotation = ScanRelativeRotation.Original, val orderIndex: Int, - @ColumnInfo(defaultValue = "null") val outputName: String? = null, ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt new file mode 100644 index 00000000..a5d69d55 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt @@ -0,0 +1,117 @@ +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.scanbridge.ScanSettingsJson +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 + +val MIGRATION_4_5 = object : Migration(4, 5) { + + override fun migrate(connection: SQLiteConnection) { + migrateCustomScanners(connection) + + migrateScannedPages(connection) + + migrateSessions(connection) + } + + // Entity: CustomScanner + // + protocolIdentifier column: String = eSCL + private fun migrateCustomScanners(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE customscanners ADD COLUMN protocolIdentifier TEXT NOT NULL DEFAULT 'eSCL'") + } + + // Entity: ScannedPage + // C originalScanSettings: type change: eSCL ScanSettings -> CommonScanSettings + // + outputName column: String? = null + private fun migrateScannedPages(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE scannedpages ADD COLUMN outputName TEXT") + + val scannedPagesQuery = connection.prepare("SELECT scanId, originalScanSettings FROM scannedpages") + val scannedPagesUpdate = connection.prepare("UPDATE scannedpages SET originalScanSettings = ? WHERE scanId = ?") + + val json = ScanSettingsJson.json + + scannedPagesUpdate.use { + scannedPagesQuery.use { + while (scannedPagesQuery.step()) { + check(scannedPagesQuery.getColumnName(0) == "scanId") + check(scannedPagesQuery.getColumnName(1) == "originalScanSettings") + + val scanId = scannedPagesQuery.getText(0) + + val oldOriginalScanSettingsString = scannedPagesQuery.getText(1) + val deserializedOldOriginalScanSettings = json + .decodeFromString(oldOriginalScanSettingsString) + + val newOriginalScanSettings = deserializedOldOriginalScanSettings.toCommonAbstraction() + val newOriginalScanSettingsString = json.encodeToString(newOriginalScanSettings) + + scannedPagesUpdate.bindText(0, newOriginalScanSettingsString) + scannedPagesUpdate.bindText(1, scanId) + scannedPagesUpdate.step() + scannedPagesUpdate.clearBindings() + } + } + } + } + + // Entity: Session + // C currentScanSettings: type change: eSCL ScanSettings? -> CommonScanSettings? + // C currentSettingsUIData: type change: ScanSettingsEnterableDataV0? -> ScanSettingsEnterableDataV1? + private fun migrateSessions(connection: SQLiteConnection) { + val json = ScanSettingsJson.json + + val sessionsQuery = connection.prepare("SELECT sessionId, currentScanSettings, currentSettingsUIData FROM sessions") + val sessionsUpdate = connection.prepare("UPDATE sessions SET currentScanSettings = ?, currentSettingsUIData = ? WHERE sessionId = ?") + + sessionsUpdate.use { + sessionsQuery.use { + while (sessionsQuery.step()) { + check(sessionsQuery.getColumnName(0) == "sessionId") + check(sessionsQuery.getColumnName(1) == "currentScanSettings") + check(sessionsQuery.getColumnName(2) == "currentSettingsUIData") + + val sessionId = sessionsQuery.getText(0) + + val oldCurrentScanSettingsString = if (!sessionsQuery.isNull(1)) { + sessionsQuery.getText(1) + } else { + null + } + + val oldCurrentSettingsUIDataString = if (!sessionsQuery.isNull(2)) { + sessionsQuery.getText(2) + } else { + null + } + + val oldCurrentScanSettings = oldCurrentScanSettingsString?.let { + json + .decodeFromString(it) + } + + val oldCurrentSettingsUIData = oldCurrentSettingsUIDataString?.let { + json + .decodeFromString(it) + } + + val newCurrentScanSettings = oldCurrentScanSettings?.toCommonAbstraction() + + val newCurrentSettingsUIData = oldCurrentSettingsUIData?.toV1() + + val newCurrentScanSettingsString = json.encodeToString(newCurrentScanSettings) + val newCurrentSettingsUIDataString = json.encodeToString(newCurrentSettingsUIData) + + sessionsUpdate.bindText(0, newCurrentScanSettingsString) + sessionsUpdate.bindText(1, newCurrentSettingsUIDataString) + sessionsUpdate.bindText(2, sessionId) + sessionsUpdate.step() + sessionsUpdate.clearBindings() + } + } + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt index 8ff81c67..04bdcb15 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt @@ -14,7 +14,14 @@ data class ScanSettingsEnterableDataV0( val widthString: String = "", val heightString: String = "", val maximumSize: Boolean = true -) +) { + fun toV1(): ScanSettingsEnterableDataV1 = ScanSettingsEnterableDataV1( + customMenuEnabled = customMenuEnabled, + widthString = widthString, + heightString = heightString, + maximumSize = maximumSize + ) +} @Serializable diff --git a/settings.gradle.kts b/settings.gradle.kts index 2986a6df..9c4e8bb5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,14 +21,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { - name = "Fireamp Snapshots" - url = uri("https://repo.fireamp.eu/repository/maven-snapshots/") - - content { - includeGroup("io.github.chrisimx") - } - } } } From b67ad390ffb634c3de1b9581650b2a5181b84b92 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 30 Jul 2026 15:23:52 +0200 Subject: [PATCH 28/38] Upgrade AnyScanKt --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab87c417..7e163376 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ concurrentFutures = "1.2.0" constraintlayoutCompose = "1.1.1" converterGson = "2.9.0" datastore = "1.2.0" -anyscankt = "2.0.8-SNAPSHOT" +anyscankt = "2.1.2" escl-mock-server = "1.0.1" itextCore = "9.3.0" kotlin = "2.3.20-Beta1" From 9bb8dca527e6a83f16f0f0ba3e92dd8218645494 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 30 Jul 2026 15:37:40 +0200 Subject: [PATCH 29/38] Fix indexing error in Room migration (4 to 5) --- .../chrisimx/scanbridge/db/migrations/Version4To5.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt index a5d69d55..544a9571 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt @@ -49,8 +49,8 @@ val MIGRATION_4_5 = object : Migration(4, 5) { val newOriginalScanSettings = deserializedOldOriginalScanSettings.toCommonAbstraction() val newOriginalScanSettingsString = json.encodeToString(newOriginalScanSettings) - scannedPagesUpdate.bindText(0, newOriginalScanSettingsString) - scannedPagesUpdate.bindText(1, scanId) + scannedPagesUpdate.bindText(1, newOriginalScanSettingsString) + scannedPagesUpdate.bindText(2, scanId) scannedPagesUpdate.step() scannedPagesUpdate.clearBindings() } @@ -105,9 +105,9 @@ val MIGRATION_4_5 = object : Migration(4, 5) { val newCurrentScanSettingsString = json.encodeToString(newCurrentScanSettings) val newCurrentSettingsUIDataString = json.encodeToString(newCurrentSettingsUIData) - sessionsUpdate.bindText(0, newCurrentScanSettingsString) - sessionsUpdate.bindText(1, newCurrentSettingsUIDataString) - sessionsUpdate.bindText(2, sessionId) + sessionsUpdate.bindText(1, newCurrentScanSettingsString) + sessionsUpdate.bindText(2, newCurrentSettingsUIDataString) + sessionsUpdate.bindText(3, sessionId) sessionsUpdate.step() sessionsUpdate.clearBindings() } From 8656de06bcae119a9926e3d2372edb66755a33fb Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 1 Aug 2026 00:24:34 +0200 Subject: [PATCH 30/38] Extract scan initiation to use case, fix B&W format issue and move scanning protocol to separate Koin module --- .../scanbridge/ScanBridgeApplication.kt | 11 ++-- .../data/ui/ScanningScreenViewModel.kt | 25 ++++----- .../scan/AndroidStartScanUseCase.kt | 56 +++++++++++++++++++ .../scanbridge/ScanProtocolRegistry.kt | 13 +++++ .../scanbridge/usecases/StartScanUseCase.kt | 15 +++++ 5 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 3d0746df..b1a40673 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -19,7 +19,6 @@ import io.github.chrisimx.scanbridge.db.DefaultScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory -import io.github.chrisimx.scanbridge.escl.EsclScanningProtocol import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.migrations.MigrationExecutor import io.github.chrisimx.scanbridge.migrations.RoomBackedMigrationExecutor @@ -27,25 +26,25 @@ import io.github.chrisimx.scanbridge.migrations.migrationsModule import io.github.chrisimx.scanbridge.model.HttpClientConfig import io.github.chrisimx.scanbridge.ports.CustomScannerRepository import io.github.chrisimx.scanbridge.ports.HttpClientFactory +import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory -import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider -import io.github.chrisimx.scanbridge.ports.ScanningProtocol import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager +import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages import io.github.chrisimx.scanbridge.repositories.DatastoreLastRouteRepository import io.github.chrisimx.scanbridge.repositories.DatastoreShownMessagesRepository import io.github.chrisimx.scanbridge.repositories.RoomLastRouteRepository +import io.github.chrisimx.scanbridge.scan.AndroidStartScanUseCase import io.github.chrisimx.scanbridge.scannerdiscovery.DiscoveryUsecase import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider import io.github.chrisimx.scanbridge.services.DebugLogService import io.github.chrisimx.scanbridge.services.FileDebugLogService import io.github.chrisimx.scanbridge.services.ScanJobRepository -import io.github.chrisimx.scanbridge.wsd.WsdScanningProtocol import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -126,9 +125,9 @@ val appModule = module { single() bind CustomScannerRepository::class single() viewModel() - single() bind ScanningProtocol::class - single() bind ScanningProtocol::class single() bind MulticastLockHandler::class + single() bind StartScanUseCase::class + includes(scanProtocols) } class ScanBridgeApplication : Application() { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 4ba38f35..88d87225 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -44,13 +44,11 @@ import io.github.chrisimx.anyscan.ScanSettingsMap import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.inches import io.github.chrisimx.scanbridge.R -import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService import io.github.chrisimx.scanbridge.datastore.appSettingsStore import io.github.chrisimx.scanbridge.db.ScanBridgeDb 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.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanRelativeRotation import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.model.ScannerHandle @@ -59,6 +57,7 @@ import io.github.chrisimx.scanbridge.model.toggleRotation import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull import io.github.chrisimx.scanbridge.services.ScanJobRepository import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore @@ -113,7 +112,8 @@ class ScanningScreenViewModel( val db: ScanBridgeDb, application: Application, val scanJobRepo: ScanJobRepository, - val initialScanSettingsProvider: InitialScanSettingsProvider + val initialScanSettingsProvider: InitialScanSettingsProvider, + val startScanUseCase: StartScanUseCase ) : AndroidViewModel(application) { private val _scanningScreenData = ScanningScreenData( @@ -396,7 +396,7 @@ class ScanningScreenViewModel( val currentSettings = session.value?.currentScanSettings if (currentSettings == null) { - Timber.e("Could not start scan job. Current scan setttings null") + Timber.e("Could not start scan job. Current scan settings null") return@launch } @@ -412,15 +412,12 @@ class ScanningScreenViewModel( return@launch } - val scanJob = ScanJob( - Uuid.generateV4(), - sessionID, - currentSettings, - scannerHandle, - createScannerConnectionSettings() + startScanUseCase.startScan( + ownerSessionId = sessionID, + scannerHandle = scannerHandle, + scanSettings = currentSettings, + connectionSettings = scannerConnectionSettings(), ) - scanJobRepo.enqueue(scanJob) - ScanJobForegroundService.startService(application) } } @@ -674,7 +671,7 @@ class ScanningScreenViewModel( } } - fun createScannerConnectionSettings() = ScannerConnectionSettings( + fun scannerConnectionSettings() = ScannerConnectionSettings( timeout.toULong(), timeout.toULong(), certificateValidationDisabled, @@ -682,7 +679,7 @@ class ScanningScreenViewModel( ) fun retrieveScannerCapabilities() = viewModelScope.launch { - val connectionSettings = createScannerConnectionSettings() + val connectionSettings = scannerConnectionSettings() val scannerCapabilities = scannerHandle.scannerCapabilities(connectionSettings) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt new file mode 100644 index 00000000..49ff77b3 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt @@ -0,0 +1,56 @@ +package io.github.chrisimx.scanbridge.scan + +import android.app.Application +import io.github.chrisimx.anyscan.ColorMode +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.FileFormat +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.enumorrawcodegen.asEnumOrRaw +import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService +import io.github.chrisimx.scanbridge.model.ScanJob +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.usecases.StartScanUseCase +import io.github.chrisimx.scanbridge.services.ScanJobRepository +import kotlin.uuid.Uuid + +class AndroidStartScanUseCase( + val scanJobRepo: ScanJobRepository, + val application: Application, +) : StartScanUseCase { + override fun startScan( + ownerSessionId: Uuid, + scannerHandle: ScannerHandle, + scanSettings: CommonScanSettings, + connectionSettings: ScannerConnectionSettings + ) { + val scanJob = ScanJob( + Uuid.generateV4(), + ownerSessionId, + scanSettings.applyFileFormat(), + scannerHandle, + connectionSettings + ) + + scanJobRepo.enqueue(scanJob) + ScanJobForegroundService.startService(application) + } + + /** + * Decides the correct file format for the scan and returns the modified [CommonScanSettings] + * with this file format. + * + * @return Modified scan settings with the correct file format selected + */ + private fun CommonScanSettings.applyFileFormat(): CommonScanSettings { + // TODO: Should also be in the scan use case. Not here + val isBlackAndWhite = this.setting[ScannerConcept.ColorMode] + ?.value == ColorMode.BlackAndWhite1.toString() + + val format = if (isBlackAndWhite) FileFormat.PDF else FileFormat.JPEG + + return this.copy( + format = format.asEnumOrRaw() + ) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt new file mode 100644 index 00000000..a6ea6a01 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt @@ -0,0 +1,13 @@ +package io.github.chrisimx.scanbridge + +import io.github.chrisimx.scanbridge.escl.EsclScanningProtocol +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.wsd.WsdScanningProtocol +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val scanProtocols = module { + single() bind ScanningProtocol::class + single() bind ScanningProtocol::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt new file mode 100644 index 00000000..1babbd12 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt @@ -0,0 +1,15 @@ +package io.github.chrisimx.scanbridge.usecases + +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import kotlin.uuid.Uuid + +interface StartScanUseCase { + fun startScan( + ownerSessionId: Uuid, + scannerHandle: ScannerHandle, + scanSettings: CommonScanSettings, + connectionSettings: ScannerConnectionSettings, + ) +} From 59243b331a5d86e6616f84cc56217d84ca0102f7 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 1 Aug 2026 17:51:14 +0200 Subject: [PATCH 31/38] Apply format --- .editorconfig | 8 +- .../scanbridge/StartupTabDefinitions.kt | 4 +- .../chrisimx/scanbridge/ScanBridgeApp.kt | 1 - .../scanbridge/ScanBridgeApplication.kt | 4 +- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 2 +- .../chrisimx/scanbridge/ScanSettings.kt | 14 +- .../chrisimx/scanbridge/ScannerBrowser.kt | 12 +- .../chrisimx/scanbridge/ScanningScreen.kt | 2 + .../chrisimx/scanbridge/StartupScreen.kt | 8 +- .../ScanJobForegroundService.kt | 12 +- .../data/model/LegacyESCLScanSettings.kt | 2 +- .../data/ui/CustomScannerViewModel.kt | 20 --- .../ui/ScanSettingsComposableStateHolder.kt | 18 +-- .../data/ui/ScanningScreenViewModel.kt | 8 +- .../scanbridge/datastore/DataStores.kt | 2 +- .../scan/AndroidStartScanUseCase.kt | 7 +- .../uicomponents/FoundScannerItem.kt | 19 ++- .../dialog/CustomScannerDialog.kt | 1 - .../scanbridge/util/ESCLKtExtensions.kt | 12 +- .../chrisimx/scanbridge/util/UIUtils.kt | 29 ++-- .../uicomponents/SelectionButtonRow.kt | 8 +- .../scanbridge/uicomponents/SelectionCard.kt | 1 - .../util/ScanSettingsChoiceLocalization.kt | 55 ++++--- .../util/ScannerConceptLocalization.kt | 10 +- ...oidMdnsDiscoverServiceInstrumentedTest.kt} | 14 +- .../kotlin/AndroidMdnsDiscoverService.kt | 65 ++++----- .../kotlin/AndroidMulticastLockHandler.kt | 12 +- .../kotlin/InetAddressKMPConverter.kt | 2 +- .../androidMain/kotlin/TimingEventListener.kt | 2 +- .../github/chrisimx/scanbridge/PaperFormat.kt | 6 +- .../chrisimx/scanbridge/ScanSettingsJson.kt | 9 -- .../KoinBasedScanningProtocolManager.kt | 25 +--- .../RoomBackedCustomScannerRepository.kt | 8 +- .../chrisimx/scanbridge/db/ScanBridgeDb.kt | 2 +- .../scanbridge/db/entities/ScannedPage.kt | 3 +- .../scanbridge/db/migrations/Version4To5.kt | 5 +- .../CommonScanSettingsTypeConverter.kt | 7 +- .../JsonSerializationTypeConverter.kt | 5 +- .../ScanSettingsTypeConverter.kt | 2 - .../ScanSettingsUiDataTypeConverter.kt | 14 +- .../escl/EsclScannerDiscoveryBackend.kt | 53 +++---- .../scanbridge/escl/EsclScanningProtocol.kt | 137 ++++++++++-------- .../scanbridge/model/DiscoveredScanner.kt | 2 +- .../scanbridge/model/HttpClientConfig.kt | 2 +- .../chrisimx/scanbridge/model/IpAddress.kt | 9 +- .../model/ScanProtocolScannedPage.kt | 8 +- .../model/ScanSettingsEnterableData.kt | 2 - .../scanbridge/model/ScannerHandle.kt | 5 +- .../scanbridge/model/ScanningError.kt | 45 +++--- .../ports/ScannerDiscoveryBackend.kt | 2 - .../scanbridge/ports/ScanningProtocol.kt | 11 +- .../scannerdiscovery/DiscoveryUsecase.kt | 6 +- .../ScannerDiscoveryScreenViewModel.kt | 5 +- .../scanbridge/usecases/StartScanUseCase.kt | 2 +- .../wsd/WsdScannerDiscoveryBackend.kt | 5 +- .../scanbridge/wsd/WsdScanningProtocol.kt | 49 ++++--- 56 files changed, 359 insertions(+), 424 deletions(-) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt rename core/src/androidDeviceTest/kotlin/{AndroidMdnsDiscoverServiceTest.kt => AndroidMdnsDiscoverServiceInstrumentedTest.kt} (92%) diff --git a/.editorconfig b/.editorconfig index 8d7a9167..2a57f6dd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -20,4 +20,10 @@ ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_e ktlint_ignore_back_ticked_identifier = false ktlint_standard_no-unused-imports = enabled max_line_length = 140 -ktlint_standard_no-wildcard-imports = disabled \ No newline at end of file +ktlint_standard_no-wildcard-imports = disabled + +[**/generated/**/*.kt] +ktlint = disabled + +[**/build/kspCaches/**/*.kt] +ktlint = disabled \ No newline at end of file diff --git a/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt b/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt index b5ad65e8..ab3fe5b3 100644 --- a/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt +++ b/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt @@ -20,7 +20,7 @@ val STARTUP_TABS = listOf( innerPadding, navController, showCustomDialog, - setShowCustomDialog, + setShowCustomDialog ) } ), @@ -30,7 +30,7 @@ val STARTUP_TABS = listOf( Icons.Filled.Settings, Icons.Outlined.Settings, false, - { innerPadding, _, _, _, -> + { innerPadding, _, _, _ -> AppSettingsScreen(innerPadding) } ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt index 94084f23..6c3f4ec7 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index b1a40673..863e3e2c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -31,7 +31,6 @@ import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager -import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages @@ -45,6 +44,7 @@ import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider import io.github.chrisimx.scanbridge.services.DebugLogService import io.github.chrisimx.scanbridge.services.FileDebugLogService import io.github.chrisimx.scanbridge.services.ScanJobRepository +import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -73,7 +73,7 @@ fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): debugLogging = false, requestTimeoutInSeconds = 2u, connectTimeoutInSeconds = 2u, - socketTimeoutInSeconds = 2u, + socketTimeoutInSeconds = 2u ) ) return ImageLoader.Builder(context) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index dfc5aee5..a54e56ab 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -159,7 +159,7 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { timeout, debug, certValidationDisabled, - Uuid.parse(scannerRoute.sessionID), + Uuid.parse(scannerRoute.sessionID) ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index 4beb638a..f8aad017 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -108,19 +108,23 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp when (parameter) { is ScanSettingParam.ScanSettingBoolParam -> TODO() + is ScanSettingParam.ScanSettingChoiceParam<*> -> { ChoiceParameterDisplay(scannerConceptLocalizedName, parameter, scanSettingsStateHolder, scannerConcept, scanSettings) } is ScanSettingParam.ScanSettingDoubleParam -> TODO() + is ScanSettingParam.ScanSettingFloatParam -> TODO() + is ScanSettingParam.ScanSettingIntParam -> TODO() + is ScanSettingParam.ScanSettingRegionParam -> { RegionParameterDisplay( scannerConceptLocalizedName, scanSettingsStateHolder, scanSettings, - userUnitString, + userUnitString ) } } @@ -133,7 +137,7 @@ private fun RegionParameterDisplay( scannerConceptLocalizedName: String, scanSettingsStateHolder: ScanSettingsComposableStateHolder, scanSettings: CommonScanSettings, - userUnitString: String, + userUnitString: String ) { val vmData by scanSettingsStateHolder.uiState.collectAsState() val availablePaperFormats by scanSettingsStateHolder.availablePaperFormats.collectAsState() @@ -175,7 +179,7 @@ private fun RegionParameterDisplay( }, label = { Text(stringResource(R.string.maximum_size)) }, selected = - vmData.maximumSize && !vmData.customMenuEnabled + vmData.maximumSize && !vmData.customMenuEnabled ) InputChip( selected = vmData.customMenuEnabled, @@ -213,8 +217,7 @@ private fun RegionParameterDisplay( } } -private fun LengthUnit.equalsLength(other: LengthUnit): Boolean = - this.toMillimeters().value == other.toMillimeters().value +private fun LengthUnit.equalsLength(other: LengthUnit): Boolean = this.toMillimeters().value == other.toMillimeters().value @Composable private fun ChoiceParameterDisplay( @@ -288,4 +291,3 @@ private fun InputSourceSelection( ) { Text(stringResource(R.string.setting_duplex)) } } } - diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index faf6c618..43abed53 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -65,7 +65,6 @@ fun ScannerList( setScannerToDelete: (Uuid?) -> Unit, setScannerToEdit: (EditedCustomScanner?) -> Unit ) { - LazyColumn( modifier = Modifier .padding(innerPadding) @@ -139,7 +138,7 @@ fun ScannerBrowser( innerPadding: PaddingValues, navController: NavController, currentlyEditedScanner: EditedCustomScanner?, - setEditedCustomDialog: (EditedCustomScanner?) -> Unit, + setEditedCustomDialog: (EditedCustomScanner?) -> Unit ) { val scannerDiscoveryScreenViewModel: ScannerDiscoveryScreenViewModel = koinViewModel() val customScanners by scannerDiscoveryScreenViewModel.customScanners.collectAsState() @@ -153,13 +152,16 @@ fun ScannerBrowser( label = "ScannerList" ) { if (it) { - ScannerList(innerPadding, + ScannerList( + innerPadding, navController, customScanners, discoveredScanners, { - deletionScheduledScanner = it - }, setEditedCustomDialog) + deletionScheduledScanner = it + }, + setEditedCustomDialog + ) } else { FullScreenError( R.drawable.twotone_wifi_find_24, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 3c580b8c..5d3f9789 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -332,8 +332,10 @@ fun ScanningScreen( scanningViewModel.scanJobRepo.events.collect { event -> when (event) { is ScanJobEvent.Completed -> scope.launch { pagerState.animateScrollToPage(scannedPages.size - 1) } + // TODO: Localize this is ScanJobEvent.Failed -> snackbarErrorRetrievingPage(event.error.unlocalizedMessage, scope, context, snackbarHostState) + is ScanJobEvent.Started -> scope.launch { pagerState.animateScrollToPage(scannedPages.size) } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt index acf85d59..d4fc666e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt @@ -34,24 +34,18 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import io.github.chrisimx.scanbridge.data.model.EditedCustomScanner -import io.github.chrisimx.scanbridge.model.DiscoveredScanner -import timber.log.Timber @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,7 +65,7 @@ data class StartupScreen( innerPadding: PaddingValues, navController: NavController, showCustomDialog: EditedCustomScanner?, - setShowCustomDialog: (EditedCustomScanner?) -> Unit, + setShowCustomDialog: (EditedCustomScanner?) -> Unit ) -> Unit ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt index 4565952d..74a4122f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt @@ -175,11 +175,13 @@ class ScanJobForegroundService : Service() { scanningFlow.collect { processingEvent -> when (processingEvent) { ScanJobProcessingEvent.Cancelled -> scanJobs.setCancel(false) + is ScanJobProcessingEvent.Failure -> { failed = true scanJobs.notifyFailed(scanJob, processingEvent.error) return@collect } + is ScanJobProcessingEvent.NewPage -> { val pageData = processingEvent.scannedPage @@ -199,6 +201,7 @@ class ScanJobForegroundService : Service() { "scan-${pageCounter.toString().padStart(4, '0')}.jpg" ) } + "application/pdf" -> { val extractedImages = extractPdfImages( scanPageFile.absolutePath, @@ -209,6 +212,7 @@ class ScanJobForegroundService : Service() { addScan(scanJob.ownerSessionId, it, scanJob.scanSettings, ScanRelativeRotation.Original) } } + else -> { failed = true scanJobs.notifyFailed(scanJob, ScanningError.UnsupportedContentType(pageData.contentType)) @@ -226,7 +230,13 @@ class ScanJobForegroundService : Service() { scanJobs.notifyCompleted(scanJob) } - suspend fun addScan(sessionID: Uuid, path: String, settings: CommonScanSettings, rotation: ScanRelativeRotation, fileName: String? = null) { + suspend fun addScan( + sessionID: Uuid, + path: String, + settings: CommonScanSettings, + rotation: ScanRelativeRotation, + fileName: String? = null + ) { Timber.d("Adding scan: $path, $rotation") db.useWriterConnection { it.immediateTransaction { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt index f4e1165b..f16d9f7b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -4,8 +4,8 @@ import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.millimeters import io.github.chrisimx.esclkt.BinaryRendering import io.github.chrisimx.esclkt.CcdChannelEnumOrRaw -import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw import io.github.chrisimx.esclkt.ContentTypeEnumOrRaw +import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw import io.github.chrisimx.esclkt.EsclScanIntentEnumOrRaw import io.github.chrisimx.esclkt.FeedDirection import io.github.chrisimx.esclkt.InputSource diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt deleted file mode 100644 index e156dc35..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (C) 2024-2025 Christian Nagel and contributors - * - * This file is part of ScanBridge. - * - * ScanBridge 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. - * - * ScanBridge 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 eSCLKt. - * If not, see . - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package io.github.chrisimx.scanbridge.data.ui diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 6dbefa1c..2e6b614a 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -78,7 +78,7 @@ class ScanSettingsComposableStateHolder( @InjectedParam private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, - private val paperFormatProvider: PaperFormatProvider, + private val paperFormatProvider: PaperFormatProvider ) { private val _uiState = MutableStateFlow(initialScanSettingsData) @@ -119,10 +119,7 @@ class ScanSettingsComposableStateHolder( capabilities.value.inputSources.first() ) - private fun validateDimensionValue( - valueString: String, - getDimension: (Area) -> LengthUnit - ): NumberValidationResult { + private fun validateDimensionValue(valueString: String, getDimension: (Area) -> LengthUnit): NumberValidationResult { if (valueString.isBlank()) { return NumberValidationResult.NotANumber } @@ -211,9 +208,12 @@ class ScanSettingsComposableStateHolder( } }.onEach { (height, width) -> updateSettings { - this.set(ScannerConcept.ScanRegion, ScanRegionValue( - Area(height.value, width.value) - )) + this.set( + ScannerConcept.ScanRegion, + ScanRegionValue( + Area(height.value, width.value) + ) + ) } }.launchIn(coroutineScope) @@ -245,7 +245,7 @@ class ScanSettingsComposableStateHolder( val newInputSource = if (duplex) { CommonInputSourceType.ADF_DUPLEX } else { - CommonInputSourceType.ADF_SIMPLEX + CommonInputSourceType.ADF_SIMPLEX } coroutineScope.launch { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 88d87225..e9b6b7f3 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -57,10 +57,10 @@ import io.github.chrisimx.scanbridge.model.toggleRotation import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings -import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull import io.github.chrisimx.scanbridge.services.ScanJobRepository import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore +import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.rotateBy90 import io.github.chrisimx.scanbridge.util.saveAsJPEG @@ -416,7 +416,7 @@ class ScanningScreenViewModel( ownerSessionId = sessionID, scannerHandle = scannerHandle, scanSettings = currentSettings, - connectionSettings = scannerConnectionSettings(), + connectionSettings = scannerConnectionSettings() ) } } @@ -519,8 +519,8 @@ class ScanningScreenViewModel( val fallbackInputSourceCaps = scannerCaps.inputSources.first() val fallbackResolution = fallbackInputSourceCaps - .furtherOptions[ScannerConcept.ScanResolution]!! - .defaultValue + .furtherOptions[ScannerConcept.ScanResolution]!! + .defaultValue val originalSettingsMap = scan.originalScanSettings.setting diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt index 0a93e0ba..8ca88fc2 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt @@ -32,7 +32,7 @@ val Context.appSettingsStore: DataStore by dataStore( chunkSizePdfExport = UInt32Value.of(sharedPrefs.getInt("chunk_size_pdf_export", 50)) rememberScanSettings = BoolValue.of(sharedPrefs.getBoolean("remember_scan_settings", true)) }.build() - }, + } ) } ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt index 49ff77b3..e100d31b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt @@ -10,14 +10,11 @@ import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings -import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.services.ScanJobRepository +import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import kotlin.uuid.Uuid -class AndroidStartScanUseCase( - val scanJobRepo: ScanJobRepository, - val application: Application, -) : StartScanUseCase { +class AndroidStartScanUseCase(val scanJobRepo: ScanJobRepository, val application: Application) : StartScanUseCase { override fun startScan( ownerSessionId: Uuid, scannerHandle: ScannerHandle, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index 7e4832e6..f48d5bc3 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt @@ -58,10 +58,7 @@ import org.koin.core.qualifier.named import timber.log.Timber @Composable -fun tintedPainterResource( - id: Int, - tint: Color, -): Painter { +fun tintedPainterResource(id: Int, tint: Color): Painter { val basePainter = painterResource(id) return remember(basePainter, tint) { @@ -98,11 +95,13 @@ fun FoundScannerItem( .padding(10.dp), onClick = { val sessionID = Uuid.random() - navController.navigate(route = ScannerRoute( - name, - scannerHandleString, - protocolIdentifier, - sessionID.toString()) + navController.navigate( + route = ScannerRoute( + name, + scannerHandleString, + protocolIdentifier, + sessionID.toString() + ) ) } ) { @@ -162,7 +161,7 @@ fun FoundScannerItem( ) } Text( - "$scannerHandleString (${protocolIdentifier})", + "$scannerHandleString ($protocolIdentifier)", style = MaterialTheme.typography.labelLarge ) } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index 1313f2c9..fa809571 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt @@ -195,7 +195,6 @@ fun CustomScannerDialog( } } - @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Preview @Composable diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index e6fc7c7a..1368d1ad 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -54,13 +54,11 @@ fun UIInputSourceType.toReadableString(): String = when (this) { * element of the list or null if the list is empty. * * @param list The list to search for the caller object. - * @return The caller object if it is contained in the list, the first element of the list if not contained, + * @return The caller object if it is contained in the list, the first element of the list if not contained, * or null if the list is empty. */ -fun T.takeIfContainedElseFirstOrNull(list: List): T? { - return if (list.contains(this)) { - this - } else { - list.firstOrNull() - } +fun T.takeIfContainedElseFirstOrNull(list: List): T? = if (list.contains(this)) { + this +} else { + list.firstOrNull() } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt index 973d9fc2..2ffa085f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt @@ -38,14 +38,11 @@ fun String.truncate(maxLength: Int): String = if (this.length <= maxLength) { this.take(maxLength.coerceAtLeast(1) - 1) + "…" } -enum class SnackbarType( - val containerColor: Color, - val contentColor: Color = Color.White, -) { +enum class SnackbarType(val containerColor: Color, val contentColor: Color = Color.White) { SUCCESS(containerColor = Color(0xFF4CAF50)), ERROR(containerColor = Color(0xFFF44336)), WARNING(containerColor = Color(0xFFFF9800)), - DEFAULT(containerColor = Color.DarkGray); + DEFAULT(containerColor = Color.DarkGray) } data class CustomSnackbarVisuals( @@ -53,7 +50,7 @@ data class CustomSnackbarVisuals( override val actionLabel: String? = null, override val duration: SnackbarDuration = SnackbarDuration.Short, override val withDismissAction: Boolean = false, - val type: SnackbarType = SnackbarType.DEFAULT, + val type: SnackbarType = SnackbarType.DEFAULT ) : SnackbarVisuals fun snackbarErrorRetrievingPage( @@ -78,18 +75,16 @@ suspend fun SnackbarHostState.showCustomSnackbar( type: SnackbarType = SnackbarType.DEFAULT, actionLabel: String? = null, duration: SnackbarDuration = SnackbarDuration.Short, - withDismissAction: Boolean = false, -): SnackbarResult { - return showSnackbar( - CustomSnackbarVisuals( - message = message, - actionLabel = actionLabel, - duration = duration, - withDismissAction = withDismissAction, - type = type - ) + withDismissAction: Boolean = false +): SnackbarResult = showSnackbar( + CustomSnackbarVisuals( + message = message, + actionLabel = actionLabel, + duration = duration, + withDismissAction = withDismissAction, + type = type ) -} +) fun snackBarError( error: String, diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt index 3fae785c..fce7a540 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt @@ -12,13 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @Composable -fun SelectionButtonRow( - title: String, - options: List, - onSet: (T?) -> Unit, - stringify: @Composable T.() -> String, - value: T?, -) { +fun SelectionButtonRow(title: String, options: List, onSet: (T?) -> Unit, stringify: @Composable T.() -> String, value: T?) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( title, diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt index 8c869adf..5179b928 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt @@ -66,4 +66,3 @@ fun SelectionCard( } } } - diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt index b85f2e1a..8843a48b 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt @@ -10,8 +10,8 @@ import io.github.chrisimx.anyscan.StringValue import io.github.chrisimx.anyscan.toAnyScanEnumOrRaw import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw import org.jetbrains.compose.resources.stringResource -import scanbridge.composeui.generated.resources.Res import scanbridge.composeui.generated.resources.* +import scanbridge.composeui.generated.resources.Res @JvmName("localizedColorModeString") @Composable @@ -20,16 +20,16 @@ fun AnyScanEnumOrRaw.localizedString(): String = when (this) { when (this.value) { ColorMode.BlackAndWhite1 -> Res.string.black_and_white ColorMode.RGB24 -> Res.string.color_scan_24 - ColorMode.RGB48 -> Res.string.color_scan_48 + ColorMode.RGB48 -> Res.string.color_scan_48 ColorMode.AutoColorDetection -> Res.string.auto_detect ColorMode.Grayscale8 -> Res.string.grayscale_8 ColorMode.Grayscale16 -> Res.string.grayscale_16 - }) + } + ) is AnyScanEnumOrRaw.Unknown -> this.asString() } - @JvmName("localizedScanIntentString") // Else the declaration would clash on JVM because of type erasure @Composable fun AnyScanEnumOrRaw.localizedString(): String = when (this) { @@ -41,39 +41,38 @@ fun AnyScanEnumOrRaw.localizedString(): String = when (this) { ScanIntent.Preview -> Res.string.scan_intent_preview ScanIntent.Object -> Res.string.scan_intent_object ScanIntent.BusinessCard -> Res.string.scan_intent_business_card - }) + } + ) is AnyScanEnumOrRaw.Unknown -> this.asString() } - @Composable -fun StringValue.toLocalizedName(scannerConcept: ScannerConcept): String { - return when (scannerConcept) { - ScannerConcept.ColorMode -> { - this.value - .toAnyScanEnumOrRaw() - .localizedString() - } +fun StringValue.toLocalizedName(scannerConcept: ScannerConcept): String = when (scannerConcept) { + ScannerConcept.ColorMode -> { + this.value + .toAnyScanEnumOrRaw() + .localizedString() + } - ScannerConcept.ScanIntent -> { - this.value - .toAnyScanEnumOrRaw() - .localizedString() - } - else -> this.toString() + ScannerConcept.ScanIntent -> { + this.value + .toAnyScanEnumOrRaw() + .localizedString() } + + else -> this.toString() } @Composable -fun SettingValue.toLocalizedName(scannerConcept: ScannerConcept): String { - return when (this) { - is DiscreteResolutionValue -> if (this.value.widthDPI == this.value.heightDPI) { - "${this.value.widthDPI}" - } else { - "${this.value.widthDPI}x${this.value.heightDPI}" - } - is StringValue -> this.toLocalizedName(scannerConcept) - else -> this.toString() +fun SettingValue.toLocalizedName(scannerConcept: ScannerConcept): String = when (this) { + is DiscreteResolutionValue -> if (this.value.widthDPI == this.value.heightDPI) { + "${this.value.widthDPI}" + } else { + "${this.value.widthDPI}x${this.value.heightDPI}" } + + is StringValue -> this.toLocalizedName(scannerConcept) + + else -> this.toString() } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt index 71be6fb6..43c0cd74 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt @@ -4,15 +4,15 @@ import androidx.compose.runtime.Composable import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.anyscan.SettingValue import org.jetbrains.compose.resources.stringResource -import scanbridge.composeui.generated.resources.Res import scanbridge.composeui.generated.resources.* +import scanbridge.composeui.generated.resources.Res @Composable -fun ScannerConcept.toLocalizedName(): String { - return stringResource(when (this) { +fun ScannerConcept.toLocalizedName(): String = stringResource( + when (this) { ScannerConcept.ColorMode -> Res.string.color_mode ScannerConcept.ScanIntent -> Res.string.intent ScannerConcept.ScanRegion -> Res.string.scan_region ScannerConcept.ScanResolution -> Res.string.resolution_dpi - }) -} + } +) diff --git a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceTest.kt b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt similarity index 92% rename from core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceTest.kt rename to core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt index a4473fdf..dbd38967 100644 --- a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceTest.kt +++ b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt @@ -117,10 +117,7 @@ class AndroidMdnsDiscoverServiceInstrumentedTest { continuation.resume(this) } - override fun onRegistrationFailed( - serviceInfo: NsdServiceInfo, - errorCode: Int - ) { + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { continuation.resumeWithException( AssertionError("NSD registration failed: $errorCode") ) @@ -128,10 +125,7 @@ class AndroidMdnsDiscoverServiceInstrumentedTest { override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) = Unit - override fun onUnregistrationFailed( - serviceInfo: NsdServiceInfo, - errorCode: Int - ) = Unit + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit } nsdManager.registerService( @@ -148,9 +142,7 @@ class AndroidMdnsDiscoverServiceInstrumentedTest { } } - private fun unregisterTestService( - listener: NsdManager.RegistrationListener - ) { + private fun unregisterTestService(listener: NsdManager.RegistrationListener) { runCatching { nsdManager.unregisterService(listener) } diff --git a/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt b/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt index 7507bc0e..8809a07b 100644 --- a/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt +++ b/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt @@ -15,14 +15,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -class AndroidMdnsDiscoverService( - val appContext: Context, - val loggerFactory: ScanBridgeLoggerFactory -) : MdnsDiscoverService { +class AndroidMdnsDiscoverService(val appContext: Context, val loggerFactory: ScanBridgeLoggerFactory) : MdnsDiscoverService { private val logger = loggerFactory.withClass(this::class) - private val _registeredListeners = mutableListOf() - private val _serviceInfoCallbacks = + private val registeredListeners = mutableListOf() + private val serviceInfoCallbacks = mutableListOf() override val foundServices: StateFlow> @@ -34,12 +31,12 @@ class AndroidMdnsDiscoverService( private val started = AtomicBoolean(false) + private var _serviceType: String? = null + override val serviceType: String? get() = _serviceType - var _serviceType: String? = null - - //private val callbackExecutor = Executors.newSingleThreadExecutor() + // private val callbackExecutor = Executors.newSingleThreadExecutor() override fun start(serviceType: String) { if (started.getAndSet(true)) { @@ -70,22 +67,22 @@ class AndroidMdnsDiscoverService( _serviceType = null - for (listener in _registeredListeners) { + for (listener in registeredListeners) { nsdManager.stopServiceDiscovery(listener) } - for (callback in _serviceInfoCallbacks) { + for (callback in serviceInfoCallbacks) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7) { nsdManager.unregisterServiceInfoCallback(callback) } } - _serviceInfoCallbacks.clear() - _registeredListeners.clear() + serviceInfoCallbacks.clear() + registeredListeners.clear() } private fun createDiscoveryListener(): NsdManager.DiscoveryListener { - val discoveryListener = object : NsdManager.DiscoveryListener { + val discoveryListener = object : NsdManager.DiscoveryListener { override fun onDiscoveryStarted(serviceType: String) { logger.info { "Service discovery started: $serviceType" } } @@ -102,7 +99,9 @@ class AndroidMdnsDiscoverService( logger.info { "Service with name ${serviceInfo.serviceName} and type ${serviceInfo.serviceType} found" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7 + ) { val serviceInfoCallback = createServiceInfoCallback(serviceInfo) nsdManager.registerServiceInfoCallback(serviceInfo, ForkJoinPool(1), serviceInfoCallback) } else { @@ -136,7 +135,7 @@ class AndroidMdnsDiscoverService( nsdManager.stopServiceDiscovery(this) } } - _registeredListeners.add(discoveryListener) + registeredListeners.add(discoveryListener) return discoveryListener } @@ -163,16 +162,13 @@ class AndroidMdnsDiscoverService( } } - private fun nsdServiceInfoToMdnsService(serviceInfo: NsdServiceInfo): MdnsService { - - return MdnsService( - serviceInfo.serviceName, - serviceInfo.serviceType, - serviceInfo.port, - getAddressesOfNsdService(serviceInfo), - serviceInfo.attributes - ) - } + private fun nsdServiceInfoToMdnsService(serviceInfo: NsdServiceInfo): MdnsService = MdnsService( + serviceInfo.serviceName, + serviceInfo.serviceType, + serviceInfo.port, + getAddressesOfNsdService(serviceInfo), + serviceInfo.attributes + ) private fun getAddressesOfNsdService(serviceInfo: NsdServiceInfo): List { val inetAddresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -184,13 +180,11 @@ class AndroidMdnsDiscoverService( return inetAddresses.map { it.toMultiplatformIpAddress() } } - private fun getServiceUniqueIdentifier(serviceInfo: NsdServiceInfo): String { - return "${serviceInfo.serviceName}.${serviceInfo.serviceType}" - } + private fun getServiceUniqueIdentifier(serviceInfo: NsdServiceInfo): String = "${serviceInfo.serviceName}.${serviceInfo.serviceType}" @RequiresExtension(extension = Build.VERSION_CODES.TIRAMISU, version = 7) private fun createServiceInfoCallback(originalServiceInfo: NsdServiceInfo): NsdManager.ServiceInfoCallback { - val serviceInfoCallback = object : NsdManager.ServiceInfoCallback { + val serviceInfoCallback = object : NsdManager.ServiceInfoCallback { override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { logger.error { "ServiceInfoCallback (${this.hashCode()}) registration failed: $errorCode" } } @@ -202,7 +196,7 @@ class AndroidMdnsDiscoverService( override fun onServiceLost() { val serviceIdentifier = getServiceUniqueIdentifier(originalServiceInfo) - _serviceInfoCallbacks.remove(this) + serviceInfoCallbacks.remove(this) nsdManager.unregisterServiceInfoCallback(this) _foundServices.update { val updateMap = it.toMutableMap() @@ -222,14 +216,11 @@ class AndroidMdnsDiscoverService( updateServiceStore(serviceIdentifier, mdnsService) } } - _serviceInfoCallbacks.add(serviceInfoCallback) + serviceInfoCallbacks.add(serviceInfoCallback) return serviceInfoCallback } - private fun updateServiceStore( - serviceIdentifier: String, - mdnsService: MdnsService - ) { + private fun updateServiceStore(serviceIdentifier: String, mdnsService: MdnsService) { _foundServices.update { it + (serviceIdentifier to mdnsService) } @@ -237,6 +228,6 @@ class AndroidMdnsDiscoverService( override fun close() { stop() - //callbackExecutor.shutdown() + // callbackExecutor.shutdown() } } diff --git a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt b/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt index 6d904b3e..f21ca143 100644 --- a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt +++ b/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt @@ -4,12 +4,9 @@ import android.net.wifi.WifiManager import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler -class AndroidMulticastLockHandler( - val application: Application, - private val loggerFactory: ScanBridgeLoggerFactory -) : MulticastLockHandler { +class AndroidMulticastLockHandler(val application: Application, private val loggerFactory: ScanBridgeLoggerFactory) : MulticastLockHandler { - private val _logger = loggerFactory.withClass(this::class) + private val logger = loggerFactory.withClass(this::class) private val wifiManager = application.getSystemService(Context.WIFI_SERVICE) as WifiManager @@ -18,16 +15,15 @@ class AndroidMulticastLockHandler( override fun acquire() { if (!lock.isHeld) { - _logger.debug { "Acquiring multicast lock" } + logger.debug { "Acquiring multicast lock" } lock.acquire() } } override fun release() { if (lock.isHeld) { - _logger.debug { "Releasing multicast lock" } + logger.debug { "Releasing multicast lock" } lock.release() } } - } diff --git a/core/src/androidMain/kotlin/InetAddressKMPConverter.kt b/core/src/androidMain/kotlin/InetAddressKMPConverter.kt index a5c2755a..350ffdfe 100644 --- a/core/src/androidMain/kotlin/InetAddressKMPConverter.kt +++ b/core/src/androidMain/kotlin/InetAddressKMPConverter.kt @@ -6,7 +6,7 @@ fun InetAddress.toMultiplatformIpAddress(): IpAddress { return when (raw.size) { 4 -> IpAddress.V4( - bytes = raw, + bytes = raw ) 16 -> IpAddress.V6( diff --git a/core/src/androidMain/kotlin/TimingEventListener.kt b/core/src/androidMain/kotlin/TimingEventListener.kt index 81b54562..55dec8ad 100644 --- a/core/src/androidMain/kotlin/TimingEventListener.kt +++ b/core/src/androidMain/kotlin/TimingEventListener.kt @@ -54,7 +54,7 @@ class TimingEventListener : EventListener() { } override fun responseBodyEnd(call: Call, byteCount: Long) { - log("responseBodyEnd ${byteCount} bytes") + log("responseBodyEnd $byteCount bytes") } override fun callFailed(call: Call, ioe: IOException) { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt index 3eccb5d1..2f6c65fe 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt @@ -8,11 +8,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable -data class PaperFormat( - val name: String, - val width: LengthUnit, - val height: LengthUnit -) { +data class PaperFormat(val name: String, val width: LengthUnit, val height: LengthUnit) { @Transient val area = Area(height, width) } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt index d73a6206..ab8e8ee0 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt @@ -1,16 +1,7 @@ package io.github.chrisimx.scanbridge -import io.github.chrisimx.anyscan.Area -import io.github.chrisimx.anyscan.DiscreteResolution -import io.github.chrisimx.anyscan.Inches -import io.github.chrisimx.anyscan.LengthUnit -import io.github.chrisimx.anyscan.Millimeters -import io.github.chrisimx.anyscan.Points -import io.github.chrisimx.anyscan.ThreeHundredthsOfInch import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic -import kotlinx.serialization.modules.subclass object ScanSettingsJson { val json = Json { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt index 89787007..3904c522 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt @@ -8,34 +8,25 @@ import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager import kotlinx.coroutines.CoroutineScope import org.koin.core.component.KoinComponent -class KoinBasedScanningProtocolManager( - private val scanningProtocols: List -): ScanningProtocolManager, KoinComponent { +class KoinBasedScanningProtocolManager(private val scanningProtocols: List) : + ScanningProtocolManager, + KoinComponent { private val protocolById = scanningProtocols.associateBy { it.protocolIdentifier } - override fun getAllProtocols(): List { - return scanningProtocols - } + override fun getAllProtocols(): List = scanningProtocols override fun getDiscoveryBackends( coroutineScope: CoroutineScope, connectionSettings: ScannerConnectionSettings - ): List { - return scanningProtocols.mapNotNull { - it.createDiscoveryBackend(coroutineScope, connectionSettings) - } + ): List = scanningProtocols.mapNotNull { + it.createDiscoveryBackend(coroutineScope, connectionSettings) } - override fun getProtocolFromIdentifier(identifier: String): ScanningProtocol? { - return protocolById[identifier] - } + override fun getProtocolFromIdentifier(identifier: String): ScanningProtocol? = protocolById[identifier] - override fun getScannerHandle( - protocolIdentifier: String, - scannerHandleStringRepresentation: String - ): ScannerHandle? { + override fun getScannerHandle(protocolIdentifier: String, scannerHandleStringRepresentation: String): ScannerHandle? { return getProtocolFromIdentifier(protocolIdentifier)?.let { protocol -> return protocol.createScannerHandle(scannerHandleStringRepresentation) } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt index 7bf49488..d130dfdf 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt @@ -6,9 +6,7 @@ import io.github.chrisimx.scanbridge.ports.CustomScannerRepository import kotlin.uuid.Uuid import kotlinx.coroutines.flow.Flow -class RoomBackedCustomScannerRepository( - appDb: ScanBridgeDb, -) : CustomScannerRepository { +class RoomBackedCustomScannerRepository(appDb: ScanBridgeDb) : CustomScannerRepository { private val customScannerDao = appDb.customScannerDao() override fun allFlow(): Flow> = customScannerDao.getAllFlow() @@ -16,9 +14,7 @@ class RoomBackedCustomScannerRepository( customScannerDao.insertAll(scanner) } - override suspend fun getById(id: Uuid): CustomScanner? { - return customScannerDao.getById(id) - } + override suspend fun getById(id: Uuid): CustomScanner? = customScannerDao.getById(id) override suspend fun deleteById(id: Uuid) { customScannerDao.deleteById(id) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt index 6cffd0c2..680f24f6 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt @@ -54,7 +54,7 @@ import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter CommonScanSettingsTypeConverter::class, ScanSettingsUiDataTypeConverterV0::class, ScanSettingsUiDataTypeConverterV1::class, - CommonScanSettingsTypeConverter::class, + CommonScanSettingsTypeConverter::class ) abstract class ScanBridgeDb : RoomDatabase() { abstract fun customScannerDao(): CustomScannerDao diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt index e09c5bfc..6f7b874c 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt @@ -1,6 +1,5 @@ package io.github.chrisimx.scanbridge.db.entities -import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -35,5 +34,5 @@ data class ScannedPage( val originalScanSettings: CommonScanSettings, val rotation: ScanRelativeRotation = ScanRelativeRotation.Original, val orderIndex: Int, - val outputName: String? = null, + val outputName: String? = null ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt index 544a9571..e0bf8606 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt @@ -5,7 +5,6 @@ import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction import io.github.chrisimx.scanbridge.ScanSettingsJson import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 val MIGRATION_4_5 = object : Migration(4, 5) { @@ -65,7 +64,9 @@ val MIGRATION_4_5 = object : Migration(4, 5) { val json = ScanSettingsJson.json val sessionsQuery = connection.prepare("SELECT sessionId, currentScanSettings, currentSettingsUIData FROM sessions") - val sessionsUpdate = connection.prepare("UPDATE sessions SET currentScanSettings = ?, currentSettingsUIData = ? WHERE sessionId = ?") + val sessionsUpdate = connection.prepare( + "UPDATE sessions SET currentScanSettings = ?, currentSettingsUIData = ? WHERE sessionId = ?" + ) sessionsUpdate.use { sessionsQuery.use { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt index 36a7ae1c..1ba2f4f6 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt @@ -2,6 +2,7 @@ package io.github.chrisimx.scanbridge.db.typeconverters import io.github.chrisimx.anyscan.CommonScanSettings -class CommonScanSettingsTypeConverter : JsonSerializationTypeConverter( - CommonScanSettings.serializer() -) +class CommonScanSettingsTypeConverter : + JsonSerializationTypeConverter( + CommonScanSettings.serializer() + ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt index 1ab2a26a..0453f171 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt @@ -2,12 +2,9 @@ package io.github.chrisimx.scanbridge.db.typeconverters import androidx.room.TypeConverter import io.github.chrisimx.scanbridge.ScanSettingsJson -import kotlin.reflect.KClass import kotlinx.serialization.KSerializer -abstract class JsonSerializationTypeConverter( - private val serializer: KSerializer -) { +abstract class JsonSerializationTypeConverter(private val serializer: KSerializer) { @TypeConverter fun fromSerializedString(serialized: String): T? = if (serialized == "null") { null diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt index b7511bbf..dd41d7ec 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt @@ -1,7 +1,5 @@ package io.github.chrisimx.scanbridge.db.typeconverters -import androidx.room.TypeConverter import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.scanbridge.ScanSettingsJson class ScanSettingsTypeConverter : JsonSerializationTypeConverter(ScanSettings.serializer()) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt index 1e152325..26d667ff 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt @@ -3,10 +3,12 @@ package io.github.chrisimx.scanbridge.db.typeconverters import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 -class ScanSettingsUiDataTypeConverterV0 : JsonSerializationTypeConverter( - ScanSettingsEnterableDataV0.serializer() -) +class ScanSettingsUiDataTypeConverterV0 : + JsonSerializationTypeConverter( + ScanSettingsEnterableDataV0.serializer() + ) -class ScanSettingsUiDataTypeConverterV1 : JsonSerializationTypeConverter( - ScanSettingsEnterableDataV1.serializer() -) +class ScanSettingsUiDataTypeConverterV1 : + JsonSerializationTypeConverter( + ScanSettingsEnterableDataV1.serializer() + ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt index e649a628..0359bf0c 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -46,20 +46,23 @@ class EsclScannerDiscoveryBackend( @InjectedParam val coroutineScope: CoroutineScope ) : ScannerDiscoveryBackend { - private val _logger = loggerFactory.withClass(this::class) + private val logger = loggerFactory.withClass(this::class) - private val SECURE_SCANNER_DISCOVER_TYPE = "_uscans._tcp" - private val INSECURE_SCANNER_DISCOVER_TYPE = "_uscan._tcp" + companion object { + private const val SECURE_SCANNER_DISCOVER_TYPE = "_uscans._tcp" + private const val INSECURE_SCANNER_DISCOVER_TYPE = "_uscan._tcp" + } private val isScannerReachableMap = mutableMapOf>() private val isScannerReachableMapMutex = Mutex() private val capabilityFetchDispatcher = Dispatchers.IO.limitedParallelism(8) + @OptIn(ExperimentalCoroutinesApi::class) private val _scanners: StateFlow> = combine( mdnsDiscoveryInsecureEscl.foundServices, - mdnsDiscoverySecureEscl.foundServices, + mdnsDiscoverySecureEscl.foundServices ) { insecure, secure -> insecure.values + secure.values } @@ -69,13 +72,12 @@ class EsclScannerDiscoveryBackend( .stateIn( scope = coroutineScope, started = SharingStarted.Eagerly, - initialValue = emptyList(), + initialValue = emptyList() ) override val scanners: StateFlow> get() = _scanners - init { check(mdnsDiscoverySecureEscl !== mdnsDiscoveryInsecureEscl) { "The MdnsDiscoverServices for secure and insecure eSCL must be different instances" @@ -93,10 +95,8 @@ class EsclScannerDiscoveryBackend( } } - private fun mdnsServicesToDiscoveredScanners( - mdnsServices: List, - ): List { - return mdnsServices.flatMap { mdnsService -> + private fun mdnsServicesToDiscoveredScanners(mdnsServices: List): List = + mdnsServices.flatMap { mdnsService -> val scannerName = mdnsService.serviceName var rs = mdnsService.txtAttributes["rs"]?.decodeToString() ?: "/" val iconUrlString = mdnsService.txtAttributes["representation"]?.decodeToString() @@ -123,21 +123,16 @@ class EsclScannerDiscoveryBackend( scannerName, scannerHandle, null, - iconUrlWithResolvedIp, + iconUrlWithResolvedIp ) } } - } - private fun String.toNullableUrl(): Url? { - return runCatching { - Url(this) - }.getOrNull() - } + private fun String.toNullableUrl(): Url? = runCatching { + Url(this) + }.getOrNull() - private fun discoveredScannersFlow( - mdnsServices: List, - ): Flow> = channelFlow { + private fun discoveredScannersFlow(mdnsServices: List): Flow> = channelFlow { val discoveredScanners = mdnsServicesToDiscoveredScanners(mdnsServices) .distinctBy { it.handle.stringRepresentation } @@ -145,11 +140,10 @@ class EsclScannerDiscoveryBackend( discoveredScanners.forEach { scanner -> launch(capabilityFetchDispatcher) { - _logger.debug { + logger.debug { "Checking reachability of ${scanner.handle.stringRepresentation}" } - val cachedReachability = isScannerReachableMapMutex.withLock { isScannerReachableMap[scanner.handle.stringRepresentation] } @@ -168,7 +162,7 @@ class EsclScannerDiscoveryBackend( val scannerCapabilitiesResult = checkReachabilityAndGetScannerCaps( scanner = scanner, connectionTimeoutSeconds = 20u, - totalTimeoutSeconds = 20u, + totalTimeoutSeconds = 20u ) isScannerReachableMapMutex.withLock { @@ -193,26 +187,26 @@ class EsclScannerDiscoveryBackend( private suspend fun checkReachabilityAndGetScannerCaps( scanner: DiscoveredScanner, connectionTimeoutSeconds: ULong, - totalTimeoutSeconds: ULong, + totalTimeoutSeconds: ULong ): ScannerCapabilitiesResult? { val settings = ScannerConnectionSettings( connectionTimeoutInSeconds = connectionTimeoutSeconds, totalTimeoutInSeconds = totalTimeoutSeconds, debugLogging = true, - allowSelfSignedCertificates = true, + allowSelfSignedCertificates = true ) val result = measureTimedValue { esclScanningProtocol.capabilitiesFor(scanner.handle, settings) } - _logger.debug { + logger.debug { "Scanner capabilities for ${scanner.handle.stringRepresentation} took ${result.duration}" } return when (result.value) { is ScannerCapabilitiesResult.Failure, is ScannerCapabilitiesResult.InvalidScannerHandle -> { - _logger.debug { "Scanner ${scanner.handle.stringRepresentation} is not reachable. Result ${result.value}" } + logger.debug { "Scanner ${scanner.handle.stringRepresentation} is not reachable. Result ${result.value}" } null } @@ -222,7 +216,7 @@ class EsclScannerDiscoveryBackend( private fun tryParseScannerUrl(address: IpAddress, serviceInfo: MdnsService, rs: String): Url? { if (address.isLinkLocal()) { - _logger.debug { "Ignoring link local address: ${address.text}, url text rep: ${address.urlHost}" } + logger.debug { "Ignoring link local address: ${address.text}, url text rep: ${address.urlHost}" } return null } @@ -238,12 +232,11 @@ class EsclScannerDiscoveryBackend( Url(result.toString()) // Try to parse it to confirm that no invalid URLs will be shown result } catch (e: Exception) { - _logger.error { "Couldn't built address from: ${address.urlHost} Exception: $e" } + logger.error { "Couldn't built address from: ${address.urlHost} Exception: $e" } null } } - override fun close() { mdnsDiscoverySecureEscl.close() mdnsDiscoveryInsecureEscl.close() diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt index 9baee187..c30dac82 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -3,12 +3,12 @@ package io.github.chrisimx.scanbridge.escl import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.esclkt.ESCLRequestClient -import io.github.chrisimx.safektor.ESCLHttpCallResult import io.github.chrisimx.esclkt.JobState import io.github.chrisimx.esclkt.ScanJob import io.github.chrisimx.esclkt.anyscancompat.CommonAbstractionConversionResult import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction import io.github.chrisimx.esclkt.anyscancompat.toESCLScanSettings +import io.github.chrisimx.safektor.ESCLHttpCallResult import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.ScanningError @@ -35,7 +35,7 @@ class EsclScanningProtocol( private val loggerFactory: ScanBridgeLoggerFactory, private val httpClientFactory: HttpClientFactory ) : ScanningProtocol { - private val _logger = loggerFactory.withClass(this::class) + private val logger = loggerFactory.withClass(this::class) override val protocolIdentifier: String get() = "eSCL" @@ -51,7 +51,6 @@ class EsclScanningProtocol( } override fun createScannerHandle(scannerIdentifier: String): ScannerHandle? { - val url = runCatching { Url(scannerIdentifier) }.getOrNull() return url?.let { @@ -71,12 +70,13 @@ class EsclScanningProtocol( coroutineScope ) - private fun Url.addTrailingSlash(): Url = Url(if (this.toString().endsWith("/")) { - this.toString() - } else { - "$this/" - }) - + private fun Url.addTrailingSlash(): Url = Url( + if (this.toString().endsWith("/")) { + this.toString() + } else { + "$this/" + } + ) override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { val scannerUrlHandle = @@ -95,15 +95,21 @@ class EsclScanningProtocol( } when (scannerCapsResult) { - is ESCLRequestClient.ScannerCapabilitiesResult.InternalBug -> return ScannerCapabilitiesResult.InternalBug(scannerCapsResult.exception) + is ESCLRequestClient.ScannerCapabilitiesResult.InternalBug -> return ScannerCapabilitiesResult.InternalBug( + scannerCapsResult.exception + ) + is ESCLRequestClient.ScannerCapabilitiesResult.RequestFailure -> return when (val error = scannerCapsResult.error) { is ESCLHttpCallResult.Error.UntrustedCertificate -> ScannerCapabilitiesResult.UntrustedCertificate(error.cause) else -> ScannerCapabilitiesResult.Failure(error) } - is ESCLRequestClient.ScannerCapabilitiesResult.ScannerCapabilitiesMalformed -> return ScannerCapabilitiesResult.ScannerCapsFormatInvalid( - scannerCapsResult.exception, - scannerCapsResult.content - ) + + is ESCLRequestClient.ScannerCapabilitiesResult.ScannerCapabilitiesMalformed -> + return ScannerCapabilitiesResult.ScannerCapsFormatInvalid( + scannerCapsResult.exception, + scannerCapsResult.content + ) + is ESCLRequestClient.ScannerCapabilitiesResult.Success -> {} } @@ -113,6 +119,7 @@ class EsclScanningProtocol( Exception("Could not convert eSCL scanner capabilities to CommonAbstraction"), scannerCapsResult.scannerCapabilities.toString() ) + is CommonAbstractionConversionResult.Success -> ScannerCapabilitiesResult.Success( conversionResult.value ) @@ -143,28 +150,30 @@ class EsclScanningProtocol( val imagesToTransfer = status?.imagesToTransfer - _logger.debug { + logger.debug { "Polling job status. Retry: $retries Result: $status imagesToTransfer: $imagesToTransfer isRunning: $isRunning" } if (!isRunning) { - _logger.debug { "Job is reported to be not running anymore. jobRunning = false" } + logger.debug { "Job is reported to be not running anymore. jobRunning = false" } val deleteResult = jobResult.cancel() - _logger.debug { "Cancelling job after (a likely) failure: $deleteResult" } + logger.debug { "Cancelling job after (a likely) failure: $deleteResult" } if (status?.jobState != JobState.Completed) { - _logger.warn { "Job info doesn't indicate completion: ${status?.jobState}" } - emit(ScanJobProcessingEvent.Failure( - ScanningError.JobCompletedInFailedState(status.toString()) - )) + logger.warn { "Job info doesn't indicate completion: ${status?.jobState}" } + emit( + ScanJobProcessingEvent.Failure( + ScanningError.JobCompletedInFailedState(status.toString()) + ) + ) } return PollResult.JobFinished } if (imagesToTransfer != null && imagesToTransfer > 0u) { - _logger.debug { "There seem to be images to transfer. Breaking out of polling loop" } + logger.debug { "There seem to be images to transfer. Breaking out of polling loop" } return PollResult.ImagesReady } @@ -184,9 +193,11 @@ class EsclScanningProtocol( handle as? UrlScannerHandle if (scannerUrlHandle == null) { - emit(ScanJobProcessingEvent.Failure( + emit( + ScanJobProcessingEvent.Failure( ScanningError.InvalidScanHandle(handle) - )) + ) + ) return@flow } @@ -195,10 +206,11 @@ class EsclScanningProtocol( val esclRequestClient = ESCLRequestClient( scannerUrlHandle.url.addTrailingSlash(), - httpClient) + httpClient + ) suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { - _logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } + logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } scanJob?.cancel() emit(ScanJobProcessingEvent.Cancelled) @@ -210,13 +222,13 @@ class EsclScanningProtocol( if (abortIfCancelling()) return@flow val esclScanSettings = jobScanSettings.toESCLScanSettings() - _logger.debug { "Creating scan job. Common scan settings: $jobScanSettings eSCL scan settings: $esclScanSettings" } + logger.debug { "Creating scan job. Common scan settings: $jobScanSettings eSCL scan settings: $esclScanSettings" } val job = esclRequestClient.createJob(esclScanSettings) - _logger.debug { "Creation request done. Result: $job" } + logger.debug { "Creation request done. Result: $job" } if (job !is ESCLRequestClient.ScannerCreateJobResult.Success) { - _logger.error { "Job creation failed. Result: $job" } + logger.error { "Job creation failed. Result: $job" } emit( ScanJobProcessingEvent.Failure(ScanningError.JobCreationFailed(job.toString())) @@ -250,61 +262,69 @@ class EsclScanningProtocol( if (abortIfCancelling(jobResult)) return@flow - _logger.debug { "Retrieving next page" } + logger.debug { "Retrieving next page" } val nextPage = jobResult.retrieveNextPage() - _logger.debug { "Next page result: $nextPage" } + logger.debug { "Next page result: $nextPage" } val status = jobResult.getJobStatus() - _logger.debug { "Retrieved job info: $status" } + logger.debug { "Retrieved job info: $status" } // val jobStateString = status?.jobState.toJobStateString(application) when (nextPage) { is ESCLRequestClient.ScannerNextPageResult.NoFurtherPages -> { - _logger.debug { "Next page result is seen as no further pages. jobRunning = false" } + logger.debug { "Next page result is seen as no further pages. jobRunning = false" } if (status?.jobState != JobState.Completed) { - _logger.warn { "Job info doesn't indicate completion: $status" } + logger.warn { "Job info doesn't indicate completion: $status" } - emit(ScanJobProcessingEvent.Failure( - ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) - )) + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + ) + ) } val deletionResult = jobResult.cancel() - _logger.debug { "Cancelling job after no further pages is reported: $deletionResult" } + logger.debug { "Cancelling job after no further pages is reported: $deletionResult" } return@flow } is ESCLRequestClient.ScannerNextPageResult.RequestFailure -> { if (nextPage.exception !is ESCLHttpCallResult.Error.HttpError) { - _logger.error { "Error while retrieving next page: $nextPage" } - emit(ScanJobProcessingEvent.Failure( - ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) - )) + logger.error { "Error while retrieving next page: $nextPage" } + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + ) + ) return@flow } val error = nextPage.exception as ESCLHttpCallResult.Error.HttpError if (status?.jobState == JobState.Completed) { - _logger.debug { "Job info indicates completion but response was not 404: $status" } + logger.debug { "Job info indicates completion but response was not 404: $status" } - emit(ScanJobProcessingEvent.Failure( - ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) - )) + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + ) + ) val deletionResult = jobResult.cancel() - _logger.debug { "Cancelling job after non-standard completion: $deletionResult" } + logger.debug { "Cancelling job after non-standard completion: $deletionResult" } return@flow } else { - _logger.error { "Not successful code while retrieving next page: $nextPage" } + logger.error { "Not successful code while retrieving next page: $nextPage" } if (error.code == 503) { // Retry with polling - _logger.debug { "503 error received. Retrying with polling" } + logger.debug { "503 error received. Retrying with polling" } polling = true continue } else { - emit(ScanJobProcessingEvent.Failure( - ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) - )) + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + ) + ) val deletionResult = jobResult.cancel() - _logger.debug { + logger.debug { "Cancelling job after not successful response while trying to retrieve page: $deletionResult" } return@flow @@ -313,7 +333,7 @@ class EsclScanningProtocol( } is ESCLRequestClient.ScannerNextPageResult.Success -> { - _logger.debug { "Received page." } + logger.debug { "Received page." } val esclScannedPage = nextPage.page val correspondingScannedPage = ScanProtocolScannedPage( @@ -326,13 +346,14 @@ class EsclScanningProtocol( else -> { jobResult.cancel() - emit(ScanJobProcessingEvent.Failure( - ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) - )) + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(nextPage.toString(), status.toString()) + ) + ) return@flow } } - } } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt index b87ce5b7..6e04d364 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt @@ -7,5 +7,5 @@ data class DiscoveredScanner( val name: String, val handle: ScannerHandle, val scannerCaps: ScannerCapabilitiesResult? = null, - val iconUrl: Url? = null, + val iconUrl: Url? = null ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt index a7c4b41d..29d6a5ec 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt @@ -15,5 +15,5 @@ fun ScannerConnectionSettings.toHttpClientConfig(): HttpClientConfig = HttpClien this.debugLogging, this.connectionTimeoutInSeconds, this.connectionTimeoutInSeconds, - this.totalTimeoutInSeconds, + this.totalTimeoutInSeconds ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt index 4db3f0f5..c666dcf3 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt @@ -10,9 +10,7 @@ sealed interface IpAddress { fun isLinkLocal(): Boolean = false // TODO: Implement link local detection - class V4( - override val bytes: ByteArray, - ) : IpAddress { + class V4(override val bytes: ByteArray) : IpAddress { init { require(bytes.size == 4) { "A IPv4 address needs to be 4 bytes long" @@ -23,10 +21,7 @@ sealed interface IpAddress { get() = bytes.joinToString(".") { it.toUByte().toString() } } - class V6( - override val bytes: ByteArray, - val scopeId: String? = null - ) : IpAddress { + class V6(override val bytes: ByteArray, val scopeId: String? = null) : IpAddress { init { require(bytes.size == 16) { "A IPv6 address needs to be 16 bytes long" diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt index 5fe01516..42262b87 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt @@ -1,11 +1,7 @@ package io.github.chrisimx.scanbridge.model -data class ScanProtocolScannedPage( - val contentType: String, - val data: ByteArray, -) { - override fun toString(): String = - "ScannedPage(contentType='$contentType', data.size=${data.size})" +data class ScanProtocolScannedPage(val contentType: String, val data: ByteArray) { + override fun toString(): String = "ScannedPage(contentType='$contentType', data.size=${data.size})" override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt index 04bdcb15..f2a4ce8f 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt @@ -1,6 +1,5 @@ package io.github.chrisimx.scanbridge.model -import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.esclkt.ScannerCapabilities import io.github.chrisimx.scanbridge.PaperFormat import io.github.chrisimx.scanbridge.loadDefaultFormats @@ -23,7 +22,6 @@ data class ScanSettingsEnterableDataV0( ) } - @Serializable data class ScanSettingsEnterableDataV1( val customMenuEnabled: Boolean = false, diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt index d47420dd..6d96629f 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt @@ -15,10 +15,7 @@ suspend fun ScannerHandle.scannerCapabilities(settings: ScannerConnectionSetting return protocol.capabilitiesFor(this, settings) } -data class UrlScannerHandle( - override val protocol: ScanningProtocol, - val url: Url -) : ScannerHandle { +data class UrlScannerHandle(override val protocol: ScanningProtocol, val url: Url) : ScannerHandle { override val stringRepresentation: String get() = url.toString() } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt index af97ecaa..5cfc4a21 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt @@ -1,32 +1,37 @@ package io.github.chrisimx.scanbridge.model sealed class ScanningError(val unlocalizedMessage: String) { - data class UnsupportedContentType(val contentType: String) : ScanningError( - "Unsupported content type: $contentType" - ) - data class InvalidScanHandle(val handle: ScannerHandle) : ScanningError( - "Invalid scan handle: $handle" - ) - data class JobCreationFailed(val creationError: String) : ScanningError( - "Job creation failed: $creationError" - ) + data class UnsupportedContentType(val contentType: String) : + ScanningError( + "Unsupported content type: $contentType" + ) + data class InvalidScanHandle(val handle: ScannerHandle) : + ScanningError( + "Invalid scan handle: $handle" + ) + data class JobCreationFailed(val creationError: String) : + ScanningError( + "Job creation failed: $creationError" + ) - data class JobCompletedInFailedState(val jobStatus: String) : ScanningError( - "Job completed but failed. Job status: $jobStatus" - ) + data class JobCompletedInFailedState(val jobStatus: String) : + ScanningError( + "Job completed but failed. Job status: $jobStatus" + ) - data class CannotGetScannerCaps(val error: String) : ScanningError( - "Cannot get scanner capabilities: $error" - ) + data class CannotGetScannerCaps(val error: String) : + ScanningError( + "Cannot get scanner capabilities: $error" + ) - data object PollingTimedOut: ScanningError( + data object PollingTimedOut : ScanningError( "Timed out waiting for images to transfer" ) - data class NextPageRetrievalError(val error: String, val jobStatus: String) : ScanningError( - "Error retrieving next page: $error. Job status: $jobStatus" - ) + data class NextPageRetrievalError(val error: String, val jobStatus: String) : + ScanningError( + "Error retrieving next page: $error. Job status: $jobStatus" + ) data class Other(val message: String) : ScanningError(message) - } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt index 01ebda7e..8e2b963c 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt @@ -1,9 +1,7 @@ package io.github.chrisimx.scanbridge.ports import io.github.chrisimx.scanbridge.model.DiscoveredScanner -import io.github.chrisimx.scanbridge.model.ScannerHandle import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow interface ScannerDiscoveryBackend : AutoCloseable { val scanners: Flow> diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt index ffa95c69..de30f205 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -13,7 +13,7 @@ data class ScannerConnectionSettings( val connectionTimeoutInSeconds: ULong = 10uL, val totalTimeoutInSeconds: ULong = 10uL, val allowSelfSignedCertificates: Boolean = false, - val debugLogging: Boolean = false, + val debugLogging: Boolean = false ) sealed class ScannerCapabilitiesResult { @@ -79,9 +79,10 @@ interface ScanningProtocol { * Each process update is provided as a [ScanJobProcessingEvent]. This also provides * the scanned pages if the scanning job succeeds. */ - fun executeScanJob(handle: ScannerHandle, - settings: ScannerConnectionSettings, - jobScanSettings: CommonScanSettings, - cancelled: StateFlow + fun executeScanJob( + handle: ScannerHandle, + settings: ScannerConnectionSettings, + jobScanSettings: CommonScanSettings, + cancelled: StateFlow ): Flow } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt index 685e84bb..db5417c8 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt @@ -8,12 +8,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf -class DiscoveryUsecase( - private val scanningProtocolManager: ScanningProtocolManager -) { +class DiscoveryUsecase(private val scanningProtocolManager: ScanningProtocolManager) { fun discoveredScanners( coroutineScope: CoroutineScope, - connectionSettings: ScannerConnectionSettings = ScannerConnectionSettings(), + connectionSettings: ScannerConnectionSettings = ScannerConnectionSettings() ): Flow> { val discoveryBackends = scanningProtocolManager.getDiscoveryBackends(coroutineScope, connectionSettings) if (discoveryBackends.isEmpty()) { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt index 0f54ce05..2af43708 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt @@ -12,10 +12,7 @@ import kotlin.uuid.Uuid import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -data class ProtocolWithExampleHandleString( - val protocolIdentifier: String, - val exampleScannerIdentifierString: String -) +data class ProtocolWithExampleHandleString(val protocolIdentifier: String, val exampleScannerIdentifierString: String) class ScannerDiscoveryScreenViewModel( val customScannerRepo: CustomScannerRepository, diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt index 1babbd12..482aba0b 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt @@ -10,6 +10,6 @@ interface StartScanUseCase { ownerSessionId: Uuid, scannerHandle: ScannerHandle, scanSettings: CommonScanSettings, - connectionSettings: ScannerConnectionSettings, + connectionSettings: ScannerConnectionSettings ) } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt index 0985cce9..a1b79b23 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam - @OptIn(ExperimentalAtomicApi::class) class WsdScannerDiscoveryBackend( val multicastLockHandler: MulticastLockHandler, @@ -27,10 +26,10 @@ class WsdScannerDiscoveryBackend( @InjectedParam val coroutineScope: CoroutineScope ) : ScannerDiscoveryBackend { - private val _logger = loggerFactory.withClass(this::class) + private val logger = loggerFactory.withClass(this::class) fun discoveryDebugLog(message: String) { - _logger.debug { message } + logger.debug { message } } val wsScannerServiceDiscovery: WsScannerServiceDiscovery = WsScannerServiceDiscovery( diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt index 155d7ac4..da476498 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt @@ -29,7 +29,7 @@ class WsdScanningProtocol( private val httpClientFactory: HttpClientFactory, private val multicastLockHandler: MulticastLockHandler ) : ScanningProtocol { - private val _logger = loggerFactory.withClass(this::class) + private val logger = loggerFactory.withClass(this::class) override val protocolIdentifier: String get() = "WSD" @@ -39,7 +39,6 @@ class WsdScanningProtocol( get() = "http://192.168.178.122/WebServices/ScannerService" override fun createScannerHandle(scannerIdentifier: String): ScannerHandle? { - val url = runCatching { Url(scannerIdentifier) }.getOrNull() return url?.let { @@ -55,7 +54,7 @@ class WsdScanningProtocol( protocol = this, loggerFactory = loggerFactory, multicastLockHandler = multicastLockHandler, - coroutineScope = coroutineScope + coroutineScope = coroutineScope ) override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { @@ -75,9 +74,12 @@ class WsdScanningProtocol( is WsdScanServiceClient.RetrieveAllScannerElementsResult.RequestFailure -> return ScannerCapabilitiesResult.Failure( allScannerElementsResult.error ) + WsdScanServiceClient.RetrieveAllScannerElementsResult.ScannerConfigurationNotFound -> return ScannerCapabilitiesResult.Failure( allScannerElementsResult - ) // TODO: Map UnknownCertificate error here to the right type + ) + + // TODO: Map UnknownCertificate error here to the right type is WsdScanServiceClient.RetrieveAllScannerElementsResult.Success -> {} } @@ -88,7 +90,6 @@ class WsdScanningProtocol( return ScannerCapabilitiesResult.Success(commonScanCaps) } - override fun executeScanJob( handle: ScannerHandle, settings: ScannerConnectionSettings, @@ -99,9 +100,11 @@ class WsdScanningProtocol( handle as? UrlScannerHandle if (scannerUrlHandle == null) { - emit(ScanJobProcessingEvent.Failure( + emit( + ScanJobProcessingEvent.Failure( ScanningError.InvalidScanHandle(handle) - )) + ) + ) return@flow } @@ -111,7 +114,7 @@ class WsdScanningProtocol( val wsdRequestClient = WsdScanServiceClient(scannerUrlHandle.url, httpClient) suspend fun abortIfCancelling(scanJob: ScanJob? = null): Boolean = if (cancelled.value) { - _logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } + logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } scanJob?.cancel() emit(ScanJobProcessingEvent.Cancelled) @@ -160,12 +163,16 @@ class WsdScanningProtocol( WsdScanServiceClient.RetrieveImageResult.NoFurtherPages -> { break } + is WsdScanServiceClient.RetrieveImageResult.Success -> {} + else -> { job.cancel() - emit(ScanJobProcessingEvent.Failure( - ScanningError.NextPageRetrievalError(newPageResult.toString(), "null") - )) + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(newPageResult.toString(), "null") + ) + ) return@flow } } @@ -174,18 +181,22 @@ class WsdScanningProtocol( if (newPage.contentType == null) { job.cancel() - emit(ScanJobProcessingEvent.Failure( - ScanningError.NextPageRetrievalError("Content type missing", "null") - )) + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError("Content type missing", "null") + ) + ) return@flow } - emit(ScanJobProcessingEvent.NewPage( - ScanProtocolScannedPage( - newPage.contentType!!, - newPage.data + emit( + ScanJobProcessingEvent.NewPage( + ScanProtocolScannedPage( + newPage.contentType!!, + newPage.data + ) ) - )) + ) } } } From 644b34aa109b9e6a1b9b9903790a45d5478b5024 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 1 Aug 2026 18:29:23 +0200 Subject: [PATCH 32/38] Fix play flavor of ScanBridge --- .../chrisimx/scanbridge/StartupTabDefinitions.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt b/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt index 35ceeff9..80853060 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt @@ -10,29 +10,27 @@ import androidx.compose.material3.ExperimentalMaterial3Api @OptIn(ExperimentalMaterial3Api::class) val STARTUP_TABS = listOf( StartupScreen( - io.github.chrisimx.scanbridge.R.string.discovery, - io.github.chrisimx.scanbridge.R.string.header_scannerbrowser, + R.string.discovery, + R.string.header_scannerbrowser, Icons.Filled.Home, Icons.Outlined.Home, true, - { innerPadding, navController, showCustomDialog, setShowCustomDialog, statefulScannerMap, statefulScannerMapSecure -> + { innerPadding, navController, showCustomDialog, setShowCustomDialog -> ScannerBrowser( innerPadding, navController, showCustomDialog, setShowCustomDialog, - statefulScannerMap, - statefulScannerMapSecure ) } ), StartupScreen( - io.github.chrisimx.scanbridge.R.string.settings, - io.github.chrisimx.scanbridge.R.string.settings, + R.string.settings, + R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false, - { innerPadding, _, _, _, _, _ -> + { innerPadding, _, _, _ -> AppSettingsScreen(innerPadding) } ), @@ -42,7 +40,7 @@ val STARTUP_TABS = listOf( BaselineHelp24, OutlineHelp24, false, - { innerPadding, _, _, _, _, _ -> + { innerPadding, _, _, _ -> SupportScreen(innerPadding) } ) From 779181668d5416063a96b6f4955bb9861418bf0e Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 1 Aug 2026 18:31:59 +0200 Subject: [PATCH 33/38] Fix input source switch back when you press the ADF button while duplex is active --- .../data/ui/ScanSettingsComposableStateHolder.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 2e6b614a..7cfa1e0d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -256,15 +256,18 @@ class ScanSettingsComposableStateHolder( } fun setInputSource(inputSource: UIInputSourceType) { - Timber.d("Input Source being set to $inputSource. Validating existing settings.") - - val newInputSource = when (inputSource) { - UIInputSourceType.PLATEN -> CommonInputSourceType.PLATEN - UIInputSourceType.ADF -> CommonInputSourceType.ADF_SIMPLEX - } + Timber.d("Input Source being set to $inputSource") coroutineScope.launch { updateSettings { + val newInputSource = when (inputSource) { + UIInputSourceType.PLATEN -> CommonInputSourceType.PLATEN + UIInputSourceType.ADF -> if (this.inputSource == CommonInputSourceType.ADF_DUPLEX) { + CommonInputSourceType.ADF_DUPLEX + } else { + CommonInputSourceType.ADF_SIMPLEX + } + } setInputSource(inputSource = newInputSource) } } From fa20b360673663a2ad5f7928dcdd96beead0eed4 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 1 Aug 2026 19:21:42 +0200 Subject: [PATCH 34/38] Update AnyScanKt --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e163376..5872cf2c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ concurrentFutures = "1.2.0" constraintlayoutCompose = "1.1.1" converterGson = "2.9.0" datastore = "1.2.0" -anyscankt = "2.1.2" +anyscankt = "2.1.3" escl-mock-server = "1.0.1" itextCore = "9.3.0" kotlin = "2.3.20-Beta1" From d6aa1c63b3e1fe589593284c767b590d41f6ade5 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 2 Aug 2026 00:24:11 +0200 Subject: [PATCH 35/38] Fix crash when rotating too many times --- .../io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt index 8f316a23..f1d77c42 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt @@ -3,8 +3,7 @@ package io.github.chrisimx.scanbridge.util import java.io.File fun File.getEditedImageName(): String { - val baseName = this.nameWithoutExtension - val extension = this.extension + val baseName = this.name.substringBefore(" edit-") - return "$baseName edit-${System.currentTimeMillis()}.$extension" + return "$baseName edit-${System.currentTimeMillis()}" } From d4d0d7ef8e661d9832a3fd2d826dff487c1ca78a Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 2 Aug 2026 01:54:17 +0200 Subject: [PATCH 36/38] Fix NavBackStackEntry.toTypedRoute() which prevented route saving --- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index a54e56ab..5a04ac42 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -74,25 +74,18 @@ data class ErrorRoute(val error: String) : BaseRoute fun NavBackStackEntry.toTypedRoute(): BaseRoute? { Timber.d("Route changed to: ${destination.route}") return when (destination.route) { - "StartUpScreenRoute" -> StartUpScreenRoute + "StartUpScreenRoute" -> this.toRoute() "CropImageRoute/{scanId}/{pageIdx}/{returnRoute}" -> { - val scanId = arguments?.getString("scanId") ?: return null - val returnRouteString = arguments?.getString("returnRoute") ?: return null - CropImageRoute(scanId, returnRouteString) + this.toRoute() } - "ScannerRoute/{scannerName}/{scannerHandleString}/{protocolId}/{sessionID}" -> { - val scannerName = arguments?.getString("scannerName") ?: return null - val scannerHandling = arguments?.getString("scannerHandleString") ?: return null - val protocolId = arguments?.getString("protocolId") ?: return null - val sessionID = arguments?.getString("sessionID") ?: return null - ScannerRoute(scannerName, scannerHandling, protocolId, sessionID) + "ScannerRoute/{scannerName}/{scannerHandleString}/{sessionID}?protocolId={protocolId}" -> { + this.toRoute() } "ErrorRoute/{error}" -> { - val error = arguments?.getString("error") ?: return null - ErrorRoute(error) + this.toRoute() } else -> null From 823113bf66b039ba48e5796129e9f2f8980795a9 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 2 Aug 2026 02:03:01 +0200 Subject: [PATCH 37/38] Fix crash that happend for specific invalid URLS --- .../scanbridge/uicomponents/dialog/CustomScannerDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index fa809571..a4089256 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt @@ -74,7 +74,7 @@ fun CustomScannerDialog( try { return Url(urlText) - } catch (_: IllegalArgumentException) { + } catch (_: Exception) { urlErrorState = context.getString(R.string.invalid_url) return null } From 1f603ad7845a3fa7aefec52f83a27dbdf3ad5139 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 2 Aug 2026 02:05:41 +0200 Subject: [PATCH 38/38] Apply format --- .../scanbridge/data/ui/ScanSettingsComposableStateHolder.kt | 1 + .../java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 7cfa1e0d..13bb94cf 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -262,6 +262,7 @@ class ScanSettingsComposableStateHolder( updateSettings { val newInputSource = when (inputSource) { UIInputSourceType.PLATEN -> CommonInputSourceType.PLATEN + UIInputSourceType.ADF -> if (this.inputSource == CommonInputSourceType.ADF_DUPLEX) { CommonInputSourceType.ADF_DUPLEX } else { diff --git a/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt b/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt index 80853060..1af2766d 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt @@ -20,7 +20,7 @@ val STARTUP_TABS = listOf( innerPadding, navController, showCustomDialog, - setShowCustomDialog, + setShowCustomDialog ) } ),