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/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/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 bd8e67da..0425345d 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,15 @@ 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.KoinBasedScanningProtocolManager +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 +18,31 @@ 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.ports.ScanningProtocolManager 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 +62,27 @@ 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, + requestTimeoutInSeconds = 2u, + connectTimeoutInSeconds = 2u, + socketTimeoutInSeconds = 2u, + ) + ) + return ImageLoader.Builder(context) + .components { + add( + KtorNetworkFetcherFactory( + httpClient = ktorClient + ) + ) + } + .build() +} val appModule = module { single>(named()) { create(::createShownMessagesDataStore) @@ -71,14 +105,22 @@ val appModule = module { single { create(::createScanBridgeDb) } + single(named("scannerIconImageLoader")) { + create(::createScannerIconImageLoader) + } + factory() bind MdnsDiscoverService::class single() single() bind LastRouteRepository::class single { (scope: CoroutineScope) -> DatastoreShownMessagesRepository(get(named()), scope) } bind ShownMessagesRepository::class + single() bind ScanningProtocolManager::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/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/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index fefdf13f..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,17 +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.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.SupportedResolutions -import io.github.chrisimx.esclkt.equalsLength +import io.github.chrisimx.anyscan.DiscreteResolution +import io.github.chrisimx.anyscan.ScanSettingParam +import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit -import io.github.chrisimx.scanbridge.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, @@ -77,20 +68,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.duplexSettingAvailable.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) @@ -99,8 +79,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 scrollState = rememberScrollState() Column( @@ -125,7 +110,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,136 +118,42 @@ 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)) } } 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 } - ) + for (parameterPair in availableParameters) { + val (scannerConcept, parameter) = parameterPair - SelectionCardWithDefault( - stringResource(R.string.intent), - intentOptions, - { - scanSettingsStateHolder.setIntent(it) - }, - { this.asString() }, - scanSettings.intent, - fitsRowVersion - ) - - 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.scanRegions}") - }, - 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.map { it.value }, + { selectedChoice -> + scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice) }, - label = { Text(stringResource(R.string.maximum_size)) }, - selected = - vmData.maximumSize && !vmData.customMenuEnabled + { this.toString() }, + scanSettings.setting[parameter.concept] ) - InputChip( - selected = vmData.customMenuEnabled, - onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, - label = { Text(stringResource(R.string.custom)) } - ) - } - 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 -> {} } } - 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 - ) - } } } @@ -316,28 +207,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 +238,7 @@ private fun ResolutionSettingButtonRowVersion( @Composable private fun ResolutionSettingCardVersion( - supportedResolutions: SupportedResolutions, + supportedResolutions: List, currentResolution: DiscreteResolution?, setSelectedResolution: (UInt, UInt) -> Unit ) { @@ -368,17 +259,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/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index cfb26d07..d8236825 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,20 @@ 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 { + val handle = discoveredScanner.handle + FoundScannerItem( + handle.stringRepresentation, + handle.protocol.protocolIdentifier, + discoveredScanner.name, + discoveredScanner.iconUrl?.toString(), + navController + ) } } - if (customScanners.isNotEmpty() && statefulScannerMap.isNotEmpty()) { + if (customScanners.isNotEmpty() && discoveredScanners.isNotEmpty()) { item { Text( stringResource(R.string.discovered_scanners), @@ -133,8 +106,10 @@ fun ScannerList( customScanners.forEach { customScanner -> item { FoundScannerItem( - customScanner.name, customScanner.url.toString(), + customScanner.protocolIdentifier, + customScanner.name, + null, navController, { setScannerToDelete(customScanner.uuid) @@ -148,7 +123,7 @@ fun ScannerList( } } - if (customScanners.isNotEmpty() && statefulScannerMap.isNotEmpty()) { + if (customScanners.isNotEmpty() && discoveredScanners.isNotEmpty()) { item { Text( stringResource(R.string.saved_scanners), @@ -167,20 +142,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 +176,7 @@ fun ScannerBrowser( R.string.custom_scanner_deletion_confirmation, onDismiss = { deletionScheduledScanner = null }, onConfirmed = { - customScannerViewModel.deleteScannerByUuid(deletionScheduledScannerImmutable) + scannerDiscoveryScreenViewModel.deleteScannerByUuid(deletionScheduledScannerImmutable) deletionScheduledScanner = null } ) @@ -217,8 +195,11 @@ fun ScannerBrowser( is EditedCustomScanner.EditingOld -> currentlyEditedScanner.scanner.uuid EditedCustomScanner.New -> Uuid.random() } + val protocolIdentifier = "eSCL" if (save) { - customScannerViewModel.addScanner(CustomScanner(uuid, name, Url(url))) + scannerDiscoveryScreenViewModel.addScanner( + CustomScanner(uuid, name, Url(url), protocolIdentifier) + ) } setEditedCustomDialog(null) if (navigate) { @@ -226,6 +207,7 @@ fun ScannerBrowser( ScannerRoute( name, url, + protocolIdentifier, sessionID.toString() ) ) 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/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index d332932a..17163380 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 @@ -55,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 @@ -93,6 +94,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 @@ -100,11 +102,13 @@ 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 import io.github.chrisimx.scanbridge.util.toReadableString -import io.ktor.http.Url +import io.github.chrisimx.scanbridge.util.toUIInputSourceType import java.io.File import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope @@ -278,16 +282,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, @@ -329,7 +332,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) } } } @@ -388,16 +392,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") @@ -643,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/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/androidservice/ScanJobForegroundService.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt index 4f95b5c9..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 @@ -7,17 +7,11 @@ 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.lifecycle.application 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.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.MainActivity import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.db.ScanBridgeDb @@ -25,14 +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.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 @@ -40,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 @@ -168,210 +158,75 @@ class ScanJobForegroundService : Service() { @OptIn(ExperimentalUuidApi::class) private suspend fun doScan(scanJob: ScanJob) { - val currentScanSettings = scanJob.scanSettings - - val esclRequestClient = ESCLRequestClient( - scanJob.scannerBaseUrl, - httpClientFactory.create(scanJob.httpClientConfig) - ) - 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: CommonScanSettings, rotation: ScanRelativeRotation, fileName: String? = null) { Timber.d("Adding scan: $path, $rotation") db.useWriterConnection { it.immediateTransaction { @@ -385,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/data/model/LegacyESCLScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt index a3eeb605..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,18 +1,18 @@ package io.github.chrisimx.scanbridge.data.model +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.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/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/data/ui/ScanSettingsComposableStateHolder.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 259258c4..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 @@ -20,47 +20,25 @@ 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.esclkt.ColorMode -import io.github.chrisimx.esclkt.ColorModeEnumOrRaw -import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EnumOrRaw -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.anyscan.CommonInputSourceCaps +import io.github.chrisimx.anyscan.CommonInputSourceType +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.LengthUnit +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.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 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.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.launch import org.koin.core.annotation.InjectedParam import timber.log.Timber @@ -72,11 +50,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 (CommonScanSettingsEditor.() -> Unit) -> Unit, @InjectedParam private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, @@ -84,108 +62,55 @@ 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 + 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 -> - 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) { - it.supportedIntents - } - - val supportedScanResolutions = selectedInputSourceCaps.derived(coroutineScope) { - it.settingProfiles[0].supportedResolutions - } - - val supportedColorModes = selectedInputSourceCaps.derived(coroutineScope) { - it.settingProfiles.firstOrNull()?.colorModes ?: listOf() - } - - val currentColorMode = scanSettings.derived(coroutineScope) { - it.colorMode - } - - val currentResolution: StateFlow = scanSettings.derived(coroutineScope) { - val x = it.xResolution - val y = it.yResolution - - if (x != null && y != null) DiscreteResolution(x, y) else null + 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.scanRegions?.regions?.firstOrNull() - } - - val heightValidationResult = combine(currentHeightText, lengthUnit, selectedInputSourceCaps) - { heightText, unit, inputSourceCaps -> - return@combine validateCustomLengthInput(heightText, unit, inputSourceCaps.maxHeight, inputSourceCaps.minHeight) - }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) - - val widthValidationResult = combine(currentWidthText, lengthUnit, selectedInputSourceCaps) - { widthText, unit, inputSourceCaps -> - return@combine validateCustomLengthInput(widthText, unit, inputSourceCaps.maxWidth, inputSourceCaps.minWidth) - }.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) { @@ -194,257 +119,69 @@ class ScanSettingsComposableStateHolder( } init { - observeHeightValidation() - observeWidthValidation() - - _uiState + /*_uiState .map { it.maximumSize } .distinctUntilChanged() .combine(selectedInputSourceCaps) { maxSize, inputSourceCaps -> Pair(maxSize, inputSourceCaps) } .filter { it.first } .onEach { (maxSize, inputSourceCaps) -> 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() - } - ) - } - }.launchIn(coroutineScope) - } - - private fun observeWidthValidation() { - widthValidationResult - .filterIsInstance() - .distinctUntilChanged() - .onEach { widthValidationResult -> - updateSettings { - val currentScanRegion = scanRegions?.regions?.firstOrNull() - 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() - 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() - height = currentHeight - } - ) + val regionParam = inputSourceCaps + .furtherOptions[ScannerConcept.ScanRegion] as ScanSettingParam.ScanSettingRegionParam? + regionParam?.maxArea?.let { maxArea -> + updateSettings { + set(ScannerConcept.ScanRegion, maxArea) } } - } - .launchIn(coroutineScope) - } - - private fun observeHeightValidation() { - heightValidationResult - .onEach { - Timber.d("Height Validation result $it") - } - .filterIsInstance() - .distinctUntilChanged() - .onEach { heightValidationResult -> - Timber.d("Height validation success result received: $heightValidationResult") - updateSettings { - val currentScanRegion = scanRegions?.regions?.firstOrNull() - - if (currentScanRegion == null) { - Timber.d("Height validation success and current scanRegion null, replacing!") - return@updateSettings copy( - scanRegions = scanRegion { - maxWidth() - 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 - height = heightValidationResult.value.threeHundredthsOfInch() - } - ) - } - } - } - .launchIn(coroutineScope) + }.launchIn(coroutineScope)*/ } fun setDuplex(duplex: Boolean) { - coroutineScope.launch { - updateSettings { - copy(duplex = duplex) - } + val duplexCurrentlyActive = duplexSettingAvailable.value + + if (duplex && !duplexCurrentlyActive) { + Timber.d("Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyActive") + return } - } - fun setColorMode(colorMode: ColorModeEnumOrRaw?) { - coroutineScope.launch { - if (colorMode is EnumOrRaw.Known && colorMode.value == ColorMode.BlackAndWhite1) { - Timber.d("Selecting b&w. Switching to PDF format") - updateSettings { - copy(colorMode = colorMode, documentFormat = "application/pdf", documentFormatExt = "application/pdf") - } - } else { - Timber.d("Selecting a color mode not b&w. Using jpeg again") - updateSettings { - copy(colorMode = colorMode, documentFormat = "image/jpeg", documentFormatExt = "image/jpeg") - } - } + val newInputSource = if (duplex) { + CommonInputSourceType.ADF_DUPLEX + } else { + CommonInputSourceType.ADF_SIMPLEX } - } - fun setInputSource(inputSource: InputSource) { - Timber.d("Input Source being set to $inputSource. Validating existing settings.") coroutineScope.launch { updateSettings { - val currentScanSettings = scanSettings.value - val uiState = uiState.value - val inputSourceCaps = uiState.capabilities.getInputSourceCaps(inputSource, currentScanSettings.duplex == true) - - val supportedResolutions = inputSourceCaps.settingProfiles[0].supportedResolutions.discreteResolutions - - val xRes = currentScanSettings.xResolution - val yRes = currentScanSettings.yResolution - - 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 = uiState.capabilities.getMaxResolution(inputSource) - - Pair(highestScanResolution.xResolution, highestScanResolution.yResolution) - } else { - Pair(xRes, yRes) - } - - val intentSupported = currentScanSettings.intent?.let { inputSourceCaps.supportedIntents.contains(it) } ?: true - - val replacementIntent = if (intentSupported) { - currentScanSettings.intent - } else { - null - } - - Timber.d( - "Input Source being set to $inputSource. " + - "Validated existing settings to: Res: ${replacementResolution.first} x ${replacementResolution.second}, Intent: $replacementIntent" - ) - copy( - inputSource = inputSource, - xResolution = replacementResolution.first, - yResolution = replacementResolution.second, - intent = replacementIntent - ) + setInputSource(inputSource = newInputSource) } } } - fun setResolution(xResolution: UInt, yResolution: UInt) { - coroutineScope.launch { - updateSettings { - copy( - xResolution = xResolution, - yResolution = yResolution - ) - } + 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 } - } - fun setIntent(intent: ScanIntentEnumOrRaw?) { coroutineScope.launch { updateSettings { - copy(intent = 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( - scanRegions = scanRegion { - width = newWidth - height = newHeight - xOffset = 0.millimeters() - yOffset = 0.millimeters() - } - ) + 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/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 1d27c139..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 @@ -37,17 +37,17 @@ 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.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.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 @@ -55,23 +55,22 @@ 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.ScanSettingsEnterableDataV1 +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.scannerCapabilities import io.github.chrisimx.scanbridge.model.toggleRotation -import io.github.chrisimx.scanbridge.ports.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 -import io.github.chrisimx.scanbridge.util.calculateDefaultESCLScanSettingsState 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 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 @@ -104,7 +103,7 @@ enum class ScanningScreenEvent { class ScanningScreenViewModel( @InjectedParam - val address: Url, + val scannerHandle: ScannerHandle, @InjectedParam val timeout: UInt, @InjectedParam @@ -116,7 +115,6 @@ class ScanningScreenViewModel( val db: ScanBridgeDb, application: Application, val scanJobRepo: ScanJobRepository, - val httpClientFactory: HttpClientFactory ) : AndroidViewModel(application) { private val _scanningScreenData = ScanningScreenData( @@ -283,31 +281,49 @@ 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 (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) } } } - val defaultScanSettingsUIData = ScanSettingsEnterableData( + val defaultScanSettingsUIData = ScanSettingsEnterableDataV1( caps ) @@ -326,118 +342,33 @@ class ScanningScreenViewModel( val savedSettingsPair = DefaultScanSettingsStore.load(application.applicationContext) 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 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 editor = CommonScanSettingsEditor( + caps, + savedSettings + ) + editor.build() + } else { + // TODO: Use actual defaults here and use swappable default providers + val editor = CommonScanSettingsEditor( + caps, + CommonScanSettings(setting = ScanSettingsMap.empty()) + ) - val validatedSettings = savedSettings.copy( - inputSource = validatedInputSource, - duplex = duplex, - intent = intent, - scanRegions = scanRegion - ) + editor.setInputSource(caps.inputSources.first().inputSourceType) + editor.setFormat(AnyScanEnumOrRaw.Known(FileFormat.JPEG)) - validatedSettings - } catch (e: Exception) { - Timber.e(e, "Error applying saved settings, using defaults") - caps.calculateDefaultESCLScanSettingsState() - } - } else { - caps.calculateDefaultESCLScanSettingsState() + editor.build() } + 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 ) @@ -490,12 +421,8 @@ class ScanningScreenViewModel( Uuid.generateV4(), sessionID, currentSettings, - address, - HttpClientConfig( - certificateValidationDisabled, - withDebugInterceptor, - timeout.toULong() - ) + scannerHandle, + createScannerConnectionSettings() ) scanJobRepo.enqueue(scanJob) ScanJobForegroundService.startService(application) @@ -543,7 +470,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)) @@ -675,7 +602,7 @@ class ScanningScreenViewModel( } else { setFileToSave(outputFile) saveFileLauncher.launch(outputFile.name) - } + }*/ } fun doZipExport(context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null) { @@ -752,29 +679,24 @@ class ScanningScreenViewModel( } } - fun createHttpClientConfig() = HttpClientConfig( + fun createScannerConnectionSettings() = ScannerConnectionSettings( + timeout.toULong(), + timeout.toULong(), certificateValidationDisabled, - withDebugInterceptor, - timeout.toULong() + withDebugInterceptor ) fun retrieveScannerCapabilities() = viewModelScope.launch { - val httpClient = httpClientFactory.create( - createHttpClientConfig() - ) - val esclClient = ESCLRequestClient( - address, - httpClient - ) + val connectionSettings = createScannerConnectionSettings() - 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/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..1e56d564 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/appsettingsstore/AppSettingsMigrationV0To1.kt @@ -0,0 +1,82 @@ +package io.github.chrisimx.scanbridge.migrations.appsettingsstore + +import androidx.datastore.core.DataMigration +import com.google.protobuf.StringValue +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.anyscancompat.CommonAbstractionConversionResult +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.scanbridge.ScanSettingsJson +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 +import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings +import io.github.chrisimx.scanbridge.proto.copy +import io.github.chrisimx.scanbridge.proto.lastUsedScanSettingsUiStateOrNull +import timber.log.Timber + +// V0 to V1, change type of serialized ScanSettings and ScanSettingsEnterableData +object AppSettingsMigrationV0To1 : DataMigration { + override suspend fun shouldMigrate(currentData: ScanBridgeSettings): Boolean { + return currentData.appSettingsVersion == 0 + } + + override suspend fun migrate(currentData: ScanBridgeSettings): ScanBridgeSettings { + /* + val json = ScanSettingsJson.json + val oldScanSettingsString = currentData.lastUsedScanSettings?.value + val oldScanSettingsParsed = oldScanSettingsString?.let { + json.decodeFromString(it) + } + + val oldEnterableData = currentData.lastUsedScanSettingsUiStateOrNull?.value + val oldEnterableDataParsed = oldEnterableData?.let { + json.decodeFromString(it) + } + + val newScanSettings = oldScanSettingsParsed?.let { + json.encodeToString(oldScanSettingsParsed.toCommonAbstraction()) + } + + val newEnterableData = oldEnterableDataParsed?.let { + val commonCapsResult = it.capabilities.toCommonAbstraction() + + val commonCaps = when (commonCapsResult) { + is CommonAbstractionConversionResult.Success -> { + commonCapsResult.value + } + + is CommonAbstractionConversionResult.Failure -> { + Timber.e("Could not convert enterable data in Migration of LastUsedScanSettings V0 to V1") + return@let null + } + } + + json.encodeToString( + ScanSettingsEnterableDataV1( + commonCaps, + it.paperFormats, + it.customMenuEnabled, + it.widthString, + it.heightString, + it.maximumSize + ) + ) + } + + + return currentData.copy { + appSettingsVersion = 1 + if (newScanSettings != null) { + lastUsedScanSettings = StringValue.of(newScanSettings) + } + if (newEnterableData != null) { + lastUsedScanSettingsUiState = StringValue.of(newEnterableData) + } + }*/ + + return currentData + } + + override suspend fun cleanUp() { + return + } +} diff --git a/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/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/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/uicomponents/FoundScannerItem.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index ec00c812..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 @@ -33,24 +33,60 @@ 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.res.painterResource import androidx.compose.ui.res.stringResource 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 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( + scannerHandleString: String, + protocolIdentifier: String, name: String, - address: String, + iconUrl: String?, navController: NavController, deleteScanner: (() -> Unit)? = null, editScanner: (() -> Unit)? = null @@ -62,7 +98,12 @@ fun FoundScannerItem( .padding(10.dp), onClick = { val sessionID = Uuid.random() - navController.navigate(route = ScannerRoute(name, address, sessionID.toString())) + navController.navigate(route = ScannerRoute( + name, + scannerHandleString, + protocolIdentifier, + sessionID.toString()) + ) } ) { Row( @@ -71,14 +112,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( + 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 +162,7 @@ fun FoundScannerItem( ) } Text( - address, + "$scannerHandleString (${protocolIdentifier})", style = MaterialTheme.typography.labelLarge ) } @@ -102,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 $address") + Timber.i("Edit button clicked for custom scanner: $name at $scannerHandleString") } ) { Icon( @@ -115,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 $address") + Timber.i("Delete button clicked for custom scanner: $name at $scannerHandleString") } ) { Icon( 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..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,17 +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.esclkt.ColorMode -import io.github.chrisimx.esclkt.ColorModeEnumOrRaw +import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw +import io.github.chrisimx.anyscan.ColorMode import io.github.chrisimx.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EnumOrRaw 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 @@ -49,14 +45,30 @@ 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 ColorModeEnumOrRaw.localizedString(context: Context): String = when (this) { - is EnumOrRaw.Known -> when (this.value) { +/** + * 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 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")) @@ -65,7 +77,7 @@ fun ColorModeEnumOrRaw.localizedString(context: Context): String = when (this) { ColorMode.Grayscale16 -> context.getString(FormattedResources.grayscale("16")) } - is EnumOrRaw.Unknown -> this.asString() + is AnyScanEnumOrRaw.Unknown -> this.asString() } fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteResolution { @@ -76,39 +88,3 @@ fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteReso return maxResolution } - -fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): ColorModeEnumOrRaw? { - 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 - } - } - 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/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/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/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 329bec11..0124f3c0 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) @@ -48,10 +56,15 @@ kotlin { api(libs.ktor.client.core) api(libs.ktor.logging) api(libs.esclkt) + api(libs.wsdkt) + api(libs.anyscanCore) + api(libs.nonThrowingKtor) // 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 +75,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/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..e4690b49 --- /dev/null +++ b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json @@ -0,0 +1,269 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "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, `protocolIdentifier` TEXT NOT NULL DEFAULT 'eSCL', 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 + }, + { + "fieldPath": "protocolIdentifier", + "columnName": "protocolIdentifier", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'eSCL'" + } + ], + "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, '967a7f604f397eeb0b79f656da762e6d')" + ] + } +} \ No newline at end of file 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/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/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/PaperFormat.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt index 411cd7d4..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.esclkt.LengthUnit -import io.github.chrisimx.esclkt.Millimeters -import io.github.chrisimx.esclkt.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 7a320eac..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,10 +1,12 @@ 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.anyscan.Area +import io.github.chrisimx.anyscan.DiscreteResolution +import io.github.chrisimx.anyscan.Inches +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.Millimeters +import io.github.chrisimx.anyscan.Points +import io.github.chrisimx.anyscan.ThreeHundredthsOfInch import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic @@ -20,7 +22,12 @@ object ScanSettingsJson { subclass(ThreeHundredthsOfInch::class) subclass(Points::class) } + polymorphic(Any::class) { + subclass(Area::class) + subclass(DiscreteResolution::class) + } } + allowStructuredMapKeys = true classDiscriminator = "type" prettyPrint = false } 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/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/db/ScanBridgeDb.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt index 376e8f42..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 @@ -25,7 +27,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 +40,10 @@ import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter AutoMigration( from = 3, to = 4 + ), + AutoMigration( + from = 4, + to = 5 ) ] ) @@ -45,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/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 ) 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..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 @@ -1,10 +1,11 @@ package io.github.chrisimx.scanbridge.db.entities +import androidx.room.ColumnInfo 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 @@ -31,7 +32,9 @@ 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 + val orderIndex: Int, + @ColumnInfo(defaultValue = "null") + val outputName: String? = 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/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt new file mode 100644 index 00000000..e649a628 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -0,0 +1,251 @@ +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.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.awaitCancellation +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.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, + 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" + + private val isScannerReachableMap = mutableMapOf>() + private val isScannerReachableMapMutex = Mutex() + + private val capabilityFetchDispatcher = Dispatchers.IO.limitedParallelism(8) + @OptIn(ExperimentalCoroutinesApi::class) + private val _scanners: StateFlow> = + combine( + mdnsDiscoveryInsecureEscl.foundServices, + mdnsDiscoverySecureEscl.foundServices, + ) { insecure, secure -> + insecure.values + secure.values + } + .flatMapLatest { allFoundServices -> + discoveredScannersFlow(allFoundServices) + } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + ) + + override val scanners: StateFlow> + get() = _scanners + + + 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 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() + + 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.map { url -> + val scannerHandle = UrlScannerHandle(esclScanningProtocol, url) + + DiscoveredScanner( + scannerName, + scannerHandle, + null, + iconUrlWithResolvedIp, + ) + } + } + } + + private fun String.toNullableUrl(): Url? { + return runCatching { + Url(this) + }.getOrNull() + } + + private fun discoveredScannersFlow( + mdnsServices: List, + ): Flow> = channelFlow { + val discoveredScanners = mdnsServicesToDiscoveredScanners(mdnsServices) + .distinctBy { it.handle.stringRepresentation } + + send(emptyList()) + + discoveredScanners.forEach { scanner -> + launch(capabilityFetchDispatcher) { + _logger.debug { + "Checking reachability of ${scanner.handle.stringRepresentation}" + } + + + val cachedReachability = isScannerReachableMapMutex.withLock { + isScannerReachableMap[scanner.handle.stringRepresentation] + } + + when (cachedReachability) { + Optional.None -> return@launch + + is Optional.Some -> { + send(listOf(scanner.copy(scannerCaps = cachedReachability.value))) + return@launch + } + + null -> Unit + } + + val scannerCapabilitiesResult = checkReachabilityAndGetScannerCaps( + scanner = scanner, + connectionTimeoutSeconds = 20u, + totalTimeoutSeconds = 20u, + ) + + isScannerReachableMapMutex.withLock { + isScannerReachableMap[scanner.handle.stringRepresentation] = scannerCapabilitiesResult + ?.let { Optional.Some(it) } + ?: Optional.None + } + + val discoveredScannerWithCaps = scanner.copy(scannerCaps = scannerCapabilitiesResult) + + if (scannerCapabilitiesResult != null) { + send(listOf(discoveredScannerWithCaps)) + } + } + } + } + .runningFold(emptyList()) { current, newlyReachable -> + (current + newlyReachable) + .distinctBy { it.handle.stringRepresentation } + } + + private suspend fun checkReachabilityAndGetScannerCaps( + scanner: DiscoveredScanner, + connectionTimeoutSeconds: ULong, + totalTimeoutSeconds: ULong, + ): ScannerCapabilitiesResult? { + val settings = ScannerConnectionSettings( + connectionTimeoutInSeconds = connectionTimeoutSeconds, + totalTimeoutInSeconds = totalTimeoutSeconds, + 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}" } + null + } + + else -> result.value + } + } + + 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..42f3a28a --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -0,0 +1,324 @@ +package io.github.chrisimx.scanbridge.escl + +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.safektor.ESCLHttpCallResult +import io.github.chrisimx.esclkt.JobState +import io.github.chrisimx.esclkt.ScanJob +import io.github.chrisimx.esclkt.anyscancompat.CommonAbstractionConversionResult +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.esclkt.anyscancompat.toESCLScanSettings +import io.github.chrisimx.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 +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.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 + +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 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, + mdnsDiscoveryInsecureEscl, + loggerFactory, + this, + coroutineScope + ) + + override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { + val scannerUrlHandle = + scanner as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) + + val httpConfig = settings.toHttpClientConfig() + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + + val scannerCapsResult = httpClient.use { + esclRequestClient.getScannerCapabilities() + } + + 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 -> return ScannerCapabilitiesResult.ScannerCapsFormatInvalid( + scannerCapsResult.exception, + scannerCapsResult.content + ) + 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 + ) + } + } + + 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, + jobScanSettings: CommonScanSettings, + cancelled: StateFlow + ): Flow = flow { + val scannerUrlHandle = + handle as? UrlScannerHandle + + if (scannerUrlHandle == null) { + emit(ScanJobProcessingEvent.Failure( + ScanningError.InvalidScanHandle(handle) + )) + return@flow + } + + val httpConfig = settings.toHttpClientConfig() + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = ESCLRequestClient(scannerUrlHandle.url, httpClient) + + suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { + _logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } + scanJob?.cancel() + + emit(ScanJobProcessingEvent.Cancelled) + true + } else { + false + } + + if (abortIfCancelling()) return@flow + + val esclScanSettings = jobScanSettings.toESCLScanSettings() + _logger.debug { "Creating scan job. Common scan settings: $jobScanSettings eSCL scan settings: $esclScanSettings" } + + val job = esclRequestClient.createJob(esclScanSettings) + + _logger.debug { "Creation request done. Result: $job" } + 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/DiscoveredScanner.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt index 4bdbfe85..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 @@ -1,3 +1,11 @@ package io.github.chrisimx.scanbridge.model -data class DiscoveredScanner(val name: String, val addresses: List) +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.ktor.http.Url + +data class DiscoveredScanner( + val name: String, + val handle: ScannerHandle, + val scannerCaps: ScannerCapabilitiesResult? = null, + val iconUrl: Url? = null, +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt index 16484b2e..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,3 +1,19 @@ package io.github.chrisimx.scanbridge.model -data class HttpClientConfig(val disableCertValidation: Boolean, val debugLogging: Boolean, val timeoutInSeconds: ULong) +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings + +data class HttpClientConfig( + val disableCertValidation: Boolean, + val debugLogging: Boolean, + val connectTimeoutInSeconds: ULong, + 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/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/ScanJob.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanJob.kt index b4aa2563..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.ktor.http.Url +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 scannerBaseUrl: Url, - val httpClientConfig: HttpClientConfig + val scanSettings: CommonScanSettings, + val scannerHandle: ScannerHandle, + val connectionSettings: ScannerConnectionSettings ) 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/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/model/ScannerHandle.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt new file mode 100644 index 00000000..d47420dd --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt @@ -0,0 +1,24 @@ +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 + +sealed interface ScannerHandle { + val protocol: ScanningProtocol + 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 +) : ScannerHandle { + override val stringRepresentation: String + get() = url.toString() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt 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/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..5eaf28d0 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -0,0 +1,82 @@ +package io.github.chrisimx.scanbridge.ports + +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 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +data class ScannerConnectionSettings( + val connectionTimeoutInSeconds: ULong = 10uL, + val totalTimeoutInSeconds: ULong = 10uL, + val allowSelfSignedCertificates: Boolean = false, + val debugLogging: Boolean = false, +) + +sealed class 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() + data class InternalBug(val exception: Any) : ScannerCapabilitiesResult() + data class Failure(val reason: Any) : ScannerCapabilitiesResult() +} + +sealed class ScanJobProcessingEvent { + data class NewPage(val scannedPage: ScanProtocolScannedPage) : ScanJobProcessingEvent() + data object Cancelled : ScanJobProcessingEvent() + data class Failure(val error: ScanningError) : ScanJobProcessingEvent() +} + +interface ScanningProtocol { + /** + * A string that uniquely identifies this protocol (e.g. "eSCL", "WSD", ...) + */ + 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 [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. + * + * 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. + */ + suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult + + /** + * Executes the scan job on the given scanner. + * + * Each process update is provided as a [ScanJobProcessingEvent]. This also provides + * the scanned pages if the scanning job succeeds. + */ + fun executeScanJob(handle: ScannerHandle, + settings: ScannerConnectionSettings, + jobScanSettings: CommonScanSettings, + cancelled: StateFlow + ): 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 new file mode 100644 index 00000000..685e84bb --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt @@ -0,0 +1,29 @@ +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.ScanningProtocolManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf + +class DiscoveryUsecase( + private val scanningProtocolManager: ScanningProtocolManager +) { + fun discoveredScanners( + coroutineScope: CoroutineScope, + connectionSettings: ScannerConnectionSettings = ScannerConnectionSettings(), + ): Flow> { + val discoveryBackends = scanningProtocolManager.getDiscoveryBackends(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/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)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a5a28f9..ab87c417 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" +anyscankt = "2.0.8-SNAPSHOT" escl-mock-server = "1.0.1" itextCore = "9.3.0" kotlin = "2.3.20-Beta1" @@ -94,8 +94,12 @@ 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" } -esclkt = { module = "io.github.chrisimx:esclkt", version.ref = "esclkt" } +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" } +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" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }