From 8677821fa17546ee1406ec6b4ad2daa6d9b61231 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 17 May 2026 15:40:39 +0200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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)) + } +}