diff --git a/.editorconfig b/.editorconfig index 8d7a9167..2a57f6dd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -20,4 +20,10 @@ ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_e ktlint_ignore_back_ticked_identifier = false ktlint_standard_no-unused-imports = enabled max_line_length = 140 -ktlint_standard_no-wildcard-imports = disabled \ No newline at end of file +ktlint_standard_no-wildcard-imports = disabled + +[**/generated/**/*.kt] +ktlint = disabled + +[**/build/kspCaches/**/*.kt] +ktlint = disabled \ No newline at end of file diff --git a/androidApp/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..be1b92a6 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -41,8 +41,8 @@ android { applicationId = "io.github.chrisimx.scanbridge" minSdk = 28 targetSdk = 36 - versionCode = 2_001_004 // format is MAJ_MIN_PAT with always 3 digits - versionName = "2.1.4" + versionCode = 2_001_005 // we just count up for each build + versionName = "2.2.0-alpha1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["escl_server_url"] = @@ -76,8 +76,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { compose = true @@ -87,7 +87,7 @@ android { kotlin { compilerOptions { - jvmTarget = JvmTarget.JVM_17 + jvmTarget = JvmTarget.JVM_21 optIn.add("kotlin.uuid.ExperimentalUuidApi") freeCompilerArgs.add("-Xnon-local-break-continue") } @@ -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/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index 87c5978d..0a7516c6 100644 --- a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -21,7 +21,6 @@ package io.github.chrisimx.scanbridge import android.app.Application import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag diff --git a/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt b/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt index 23822084..ab3fe5b3 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 + setShowCustomDialog ) } ), @@ -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/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index a38c8d6b..0b470630 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -9,6 +9,9 @@ + + + >(named()) { create(::createShownMessagesDataStore) @@ -71,14 +108,26 @@ 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 + single() bind InitialScanSettingsProvider::class + single() bind PaperFormatProvider::class factory() viewModel() - viewModel() + single() bind CustomScannerRepository::class + single() + viewModel() + single() bind MulticastLockHandler::class + single() bind StartScanUseCase::class + includes(scanProtocols) } 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..5a04ac42 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,20 @@ 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.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNames +import org.koin.compose.koinInject import timber.log.Timber @Serializable @@ -53,7 +54,14 @@ object StartUpScreenRoute : BaseRoute @Serializable @SerialName("ScannerRoute") -data class ScannerRoute(val scannerName: String, val scannerURL: String, val sessionID: String) : BaseRoute +@OptIn(ExperimentalSerializationApi::class) +data class ScannerRoute( + val scannerName: String, + @JsonNames("scannerHandle", "scannerURL") + val scannerHandleString: String, + val protocolId: String = "eSCL", + val sessionID: String +) : BaseRoute @Serializable @SerialName("CropImageRoute") @@ -66,24 +74,18 @@ data class ErrorRoute(val error: String) : BaseRoute fun NavBackStackEntry.toTypedRoute(): BaseRoute? { Timber.d("Route changed to: ${destination.route}") return when (destination.route) { - "StartUpScreenRoute" -> StartUpScreenRoute + "StartUpScreenRoute" -> this.toRoute() "CropImageRoute/{scanId}/{pageIdx}/{returnRoute}" -> { - val scanId = arguments?.getString("scanId") ?: return null - val returnRouteString = arguments?.getString("returnRoute") ?: return null - CropImageRoute(scanId, returnRouteString) + this.toRoute() } - "ScannerRoute/{scannerName}/{scannerURL}/{sessionID}" -> { - val scannerName = arguments?.getString("scannerName") ?: return null - val scannerURL = arguments?.getString("scannerURL") ?: return null - val sessionID = arguments?.getString("sessionID") ?: return null - ScannerRoute(scannerName, scannerURL, sessionID) + "ScannerRoute/{scannerName}/{scannerHandleString}/{sessionID}?protocolId={protocolId}" -> { + this.toRoute() } "ErrorRoute/{error}" -> { - val error = arguments?.getString("error") ?: return null - ErrorRoute(error) + this.toRoute() } else -> null @@ -94,6 +96,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 +133,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 + Uuid.parse(scannerRoute.sessionID) ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt index fefdf13f..f8aad017 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -20,7 +20,6 @@ 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 @@ -30,10 +29,8 @@ 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 @@ -43,54 +40,34 @@ import androidx.compose.material3.ToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext 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.CommonScanSettings +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.ScanSettingParam +import io.github.chrisimx.anyscan.ScannerConcept import io.github.chrisimx.scanbridge.data.ui.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanSettingsLengthUnit -import io.github.chrisimx.scanbridge.model.NumberValidationResult +import io.github.chrisimx.scanbridge.uicomponents.SelectionButtonRow +import io.github.chrisimx.scanbridge.uicomponents.SelectionCard import io.github.chrisimx.scanbridge.uicomponents.SizeBasedConditionalView import io.github.chrisimx.scanbridge.uicomponents.ValidatedDimensionsTextEdit -import io.github.chrisimx.scanbridge.util.localizedString +import io.github.chrisimx.scanbridge.util.UIInputSourceType +import io.github.chrisimx.scanbridge.util.toLocalizedName import io.github.chrisimx.scanbridge.util.toReadableString -import timber.log.Timber - -@OptIn( - ExperimentalLayoutApi::class, - ExperimentalFoundationApi::class -) -private val TAG = "ScanSettings" @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComposableStateHolder) { - 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 +76,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( @@ -111,281 +93,201 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.input_source)) - FlowRow( - Modifier.fillMaxWidth().padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) - ) { - SingleChoiceSegmentedButtonRow { - inputSourceOptions.forEachIndexed { index, inputSource -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = inputSourceOptions.size - ), - onClick = { scanSettingsStateHolder.setInputSource(inputSource) }, - selected = scanSettings.inputSource == inputSource - ) { - Text(inputSource.toReadableString(context)) - } - } - } - ToggleButton( - enabled = duplexCurrentlyAvailable, - checked = scanSettings.duplex == true, - onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } - ) { Text(stringResource(R.string.setting_duplex)) } - } + InputSourceSelection( + inputSourceOptions, + scanSettingsStateHolder, + selectedInputSource, + duplexCurrentlyAvailable, + duplexUsed + ) - var fitsRowVersion by remember { mutableStateOf(false) } + for (parameterPair in availableParameters) { + val (scannerConcept, parameter) = parameterPair - SizeBasedConditionalView( - modifier = Modifier, - largeView = { - ResolutionSettingButtonRowVersion(supportedResolutions, currentResolution) { x, y -> - scanSettingsStateHolder.setResolution(x, y) - } - }, - smallView = { - ResolutionSettingCardVersion(supportedResolutions, currentResolution) { x, y -> - scanSettingsStateHolder.setResolution(x, y) + val scannerConceptLocalizedName = scannerConcept.toLocalizedName() + + when (parameter) { + is ScanSettingParam.ScanSettingBoolParam -> TODO() + + is ScanSettingParam.ScanSettingChoiceParam<*> -> { + ChoiceParameterDisplay(scannerConceptLocalizedName, parameter, scanSettingsStateHolder, scannerConcept, scanSettings) } - }, - onViewChosen = { fitsRowVersion = it } - ) - SelectionCardWithDefault( - stringResource(R.string.intent), - intentOptions, - { - scanSettingsStateHolder.setIntent(it) - }, - { this.asString() }, - scanSettings.intent, - fitsRowVersion - ) + is ScanSettingParam.ScanSettingDoubleParam -> TODO() - SelectionCardWithDefault( - stringResource(R.string.color_mode), - supportedColorModes, - { - scanSettingsStateHolder.setColorMode(it) - }, - { this.localizedString(context) }, - currentColorMode - ) + is ScanSettingParam.ScanSettingFloatParam -> TODO() - 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 - ) + is ScanSettingParam.ScanSettingIntParam -> TODO() - 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() - }, - label = { Text(stringResource(R.string.maximum_size)) }, - selected = - vmData.maximumSize && !vmData.customMenuEnabled - ) - InputChip( - selected = vmData.customMenuEnabled, - onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, - label = { Text(stringResource(R.string.custom)) } + is ScanSettingParam.ScanSettingRegionParam -> { + RegionParameterDisplay( + scannerConceptLocalizedName, + scanSettingsStateHolder, + scanSettings, + userUnitString ) } - 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 - ) - } - } } } - 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 - ) - } } } @Composable -private fun SelectionCardWithDefault( - title: String, - options: List, - onSet: (T?) -> Unit, - stringify: T.() -> String, - value: T?, - isSmallRowAbove: Boolean = false +private fun RegionParameterDisplay( + scannerConceptLocalizedName: String, + scanSettingsStateHolder: ScanSettingsComposableStateHolder, + scanSettings: CommonScanSettings, + userUnitString: String ) { + val vmData by scanSettingsStateHolder.uiState.collectAsState() + val availablePaperFormats by scanSettingsStateHolder.availablePaperFormats.collectAsState() + val currentScanRegion = scanSettings.setting[ScannerConcept.ScanRegion] + + val widthValidationResult by scanSettingsStateHolder.validationResultWidth.collectAsState() + val heightValidationResult by scanSettingsStateHolder.validationResultHeight.collectAsState() + OutlinedCard( modifier = Modifier .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = if (isSmallRowAbove) 30.dp else 15.dp, bottom = 15.dp) + .padding(start = 20.dp, end = 20.dp, top = 15.dp, bottom = 15.dp) ) { Column(modifier = Modifier.padding(20.dp)) { Text( - title, + scannerConceptLocalizedName, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) FlowRow( Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly ) { - options.forEach { option -> - val name = option.stringify() + availablePaperFormats.forEach { paperFormat -> InputChip( onClick = { - onSet(option) + scanSettingsStateHolder.setFormat(paperFormat) }, - label = { Text(name) }, - selected = value == option + label = { Text(paperFormat.name) }, + selected = !vmData.customMenuEnabled && !vmData.maximumSize && + currentScanRegion?.value?.width?.equalsLength(paperFormat.width) == true && + currentScanRegion?.value?.height?.equalsLength(paperFormat.height) == true ) } InputChip( onClick = { - onSet(null) + scanSettingsStateHolder.selectMaxRegion() }, - label = { Text(stringResource(R.string.default_string)) }, - selected = value == null + label = { Text(stringResource(R.string.maximum_size)) }, + selected = + vmData.maximumSize && !vmData.customMenuEnabled ) + InputChip( + selected = vmData.customMenuEnabled, + onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, + label = { Text(stringResource(R.string.custom)) } + ) + } + AnimatedVisibility(vmData.customMenuEnabled) { + Row(horizontalArrangement = Arrangement.SpaceEvenly) { + ValidatedDimensionsTextEdit( + vmData.widthString, + modifier = Modifier + .weight(1f) + .padding(end = 10.dp), + stringResource(R.string.width_in_unit, userUnitString), + { newText: String -> + scanSettingsStateHolder.setCustomWidthTextFieldContent( + newText + ) + }, + widthValidationResult + ) + ValidatedDimensionsTextEdit( + vmData.heightString, + modifier = Modifier + .weight(1f) + .padding(start = 10.dp), + stringResource(R.string.height_in_unit, userUnitString), + { scanSettingsStateHolder.setCustomHeightTextFieldContent(it) }, + heightValidationResult + ) + } } } } } +private fun LengthUnit.equalsLength(other: LengthUnit): Boolean = this.toMillimeters().value == other.toMillimeters().value + @Composable -private fun ResolutionSettingButtonRowVersion( - supportedResolutions: SupportedResolutions, - currentResolution: DiscreteResolution?, - setSelectedResolution: (UInt, UInt) -> Unit +private fun ChoiceParameterDisplay( + scannerConceptLocalizedName: String, + parameter: ScanSettingParam.ScanSettingChoiceParam<*>, + scanSettingsStateHolder: ScanSettingsComposableStateHolder, + scannerConcept: ScannerConcept<*>, + scanSettings: CommonScanSettings ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.resolution_dpi)) - SingleChoiceSegmentedButtonRow { - supportedResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = supportedResolutions.discreteResolutions.size - ), - onClick = { - setSelectedResolution(discreteResolution.xResolution, discreteResolution.yResolution) - }, - selected = currentResolution == discreteResolution - ) { - if (discreteResolution.xResolution == discreteResolution.yResolution) { - Text("${discreteResolution.xResolution}") - } else { - Text("${discreteResolution.xResolution}x${discreteResolution.yResolution}") - } - } - } + SizeBasedConditionalView( + largeView = { + SelectionButtonRow( + scannerConceptLocalizedName, + parameter.availableChoices.map { it.value }, + { selectedChoice -> + scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice) + }, + { this.toLocalizedName(scannerConcept) }, + scanSettings.setting[parameter.concept] + ) + }, + smallView = { + SelectionCard( + scannerConceptLocalizedName, + parameter.availableChoices.map { it.value }, + { selectedChoice -> + scanSettingsStateHolder.setSetting(parameter.concept, selectedChoice) + }, + { this.toLocalizedName(scannerConcept) }, + scanSettings.setting[parameter.concept] + ) } - } + ) } @Composable -private fun ResolutionSettingCardVersion( - supportedResolutions: SupportedResolutions, - currentResolution: DiscreteResolution?, - setSelectedResolution: (UInt, UInt) -> Unit +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun InputSourceSelection( + inputSourceOptions: List, + scanSettingsStateHolder: ScanSettingsComposableStateHolder, + selectedInputSource: UIInputSourceType, + duplexCurrentlyAvailable: Boolean, + duplexUsed: Boolean ) { - OutlinedCard( - modifier = Modifier + Text(stringResource(R.string.input_source)) + FlowRow( + Modifier .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 30.dp, bottom = 15.dp) + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - stringResource(R.string.resolution_dpi), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - - FlowRow( - Modifier.fillMaxWidth(), - - horizontalArrangement = Arrangement.SpaceEvenly - ) { - supportedResolutions.discreteResolutions.forEachIndexed { index, discreteResolution -> - val text = if (discreteResolution.xResolution == discreteResolution.yResolution) { - "${discreteResolution.xResolution}" - } else { - "${discreteResolution.xResolution}x${discreteResolution.yResolution}" - } - InputChip( - onClick = { - setSelectedResolution( - discreteResolution.xResolution, - discreteResolution.yResolution - ) - }, - label = { Text(text) }, - selected = currentResolution == discreteResolution - ) + SingleChoiceSegmentedButtonRow { + inputSourceOptions.forEachIndexed { index, inputSource -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = inputSourceOptions.size + ), + onClick = { scanSettingsStateHolder.setInputSource(inputSource) }, + selected = selectedInputSource == inputSource + ) { + Text(inputSource.toReadableString()) } } } + ToggleButton( + enabled = duplexCurrentlyAvailable, + checked = duplexUsed, + onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } + ) { Text(stringResource(R.string.setting_duplex)) } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index cfb26d07..43abed53 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,7 @@ package io.github.chrisimx.scanbridge -import android.content.Context -import android.net.nsd.NsdManager +import android.annotation.SuppressLint import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -37,59 +36,35 @@ 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.lifecycle.compose.collectAsStateWithLifecycle 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.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel import io.github.chrisimx.scanbridge.uicomponents.FoundScannerItem import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.dialog.CustomScannerDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.DeletionDialog -import io.ktor.http.Url import java.util.* import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import org.koin.androidx.compose.koinViewModel -import 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))) -} @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 .padding(innerPadding) @@ -98,25 +73,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 +103,10 @@ fun ScannerList( customScanners.forEach { customScanner -> item { FoundScannerItem( - customScanner.name, customScanner.url.toString(), + customScanner.protocolIdentifier, + customScanner.name, + null, navController, { setScannerToDelete(customScanner.uuid) @@ -148,7 +120,7 @@ fun ScannerList( } } - if (customScanners.isNotEmpty() && statefulScannerMap.isNotEmpty()) { + if (customScanners.isNotEmpty() && discoveredScanners.isNotEmpty()) { item { Text( stringResource(R.string.saved_scanners), @@ -166,23 +138,30 @@ fun ScannerBrowser( innerPadding: PaddingValues, navController: NavController, currentlyEditedScanner: EditedCustomScanner?, - setEditedCustomDialog: (EditedCustomScanner?) -> Unit, - statefulScannerMap: SnapshotStateMap, - statefulScannerMapSecure: SnapshotStateMap + setEditedCustomDialog: (EditedCustomScanner?) -> Unit ) { - 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.collectAsStateWithLifecycle() + val protocolsForCustomScanners = scannerDiscoveryScreenViewModel.protocolsForCustomScanners 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, { - deletionScheduledScanner = it - }, setEditedCustomDialog) + ScannerList( + innerPadding, + navController, + customScanners, + discoveredScanners, + { + deletionScheduledScanner = it + }, + setEditedCustomDialog + ) } else { FullScreenError( R.drawable.twotone_wifi_find_24, @@ -198,7 +177,7 @@ fun ScannerBrowser( R.string.custom_scanner_deletion_confirmation, onDismiss = { deletionScheduledScanner = null }, onConfirmed = { - customScannerViewModel.deleteScannerByUuid(deletionScheduledScannerImmutable) + scannerDiscoveryScreenViewModel.deleteScannerByUuid(deletionScheduledScannerImmutable) deletionScheduledScanner = null } ) @@ -208,24 +187,28 @@ fun ScannerBrowser( val context = LocalContext.current CustomScannerDialog( + protocolsWithExampleHandle = protocolsForCustomScanners, onDismiss = { setEditedCustomDialog(null) }, - onConnectClicked = { name, url, save, navigate -> + onConnectClicked = { name, url, protocol, save, navigate -> + @SuppressLint("LocalContextGetResourceValueCall") val name = name.ifEmpty { context.getString(R.string.custom_scanner) } - val url = if (url.toString().endsWith("/")) url.toString() else "$url/" val sessionID = Uuid.random() val uuid = when (currentlyEditedScanner) { is EditedCustomScanner.EditingOld -> currentlyEditedScanner.scanner.uuid EditedCustomScanner.New -> Uuid.random() } if (save) { - customScannerViewModel.addScanner(CustomScanner(uuid, name, Url(url))) + scannerDiscoveryScreenViewModel.addScanner( + CustomScanner(uuid, name, url, protocol) + ) } setEditedCustomDialog(null) if (navigate) { navController.navigate( ScannerRoute( name, - url, + url.toString(), + protocol, 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..5d3f9789 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,16 +54,18 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton 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 import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -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,10 @@ 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,19 +394,44 @@ 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() }, - modifier = Modifier.testTag("snackbar_dismiss") - ) { - Icon(Icons.Default.Close, contentDescription = "Dismiss") + Row { + val actionLabel = visuals?.actionLabel + if (actionLabel != null) { + Button( + onClick = { data.performAction() }, + colors = ButtonColors( + type.containerColor, + type.contentColor, + type.containerColor, + type.contentColor + ), + modifier = Modifier.testTag("snackbar_perform_action") + ) { + Text(actionLabel) + } + } + + IconButton( + onClick = { data.dismiss() }, + colors = IconButtonColors( + type.containerColor, + type.contentColor, + type.containerColor, + type.contentColor + ), + modifier = Modifier.testTag("snackbar_dismiss") + ) { + Icon(Icons.Default.Close, contentDescription = "Dismiss") + } } } ) { @@ -643,9 +674,7 @@ fun ScanContent( if (currentPages.size > pagerState.currentPage) { Text( - currentPage?.originalScanSettings?.inputSource?.toReadableString( - context - ).toString() + currentPage?.originalScanSettings?.inputSource?.toUIInputSourceType()?.toReadableString().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..d4fc666e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt @@ -34,24 +34,18 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import io.github.chrisimx.scanbridge.data.model.EditedCustomScanner -import io.github.chrisimx.scanbridge.model.DiscoveredScanner -import timber.log.Timber @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,9 +65,7 @@ data class StartupScreen( innerPadding: PaddingValues, navController: NavController, showCustomDialog: EditedCustomScanner?, - setShowCustomDialog: (EditedCustomScanner?) -> Unit, - statefulScannerMap: SnapshotStateMap, - statefulScannerMapSecure: SnapshotStateMap + setShowCustomDialog: (EditedCustomScanner?) -> Unit ) -> Unit ) @@ -86,31 +78,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 +126,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..74a4122f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/androidservice/ScanJobForegroundService.kt @@ -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,85 @@ class ScanJobForegroundService : Service() { @OptIn(ExperimentalUuidApi::class) private suspend fun doScan(scanJob: ScanJob) { - val currentScanSettings = scanJob.scanSettings + scanJobs.notifyStarted(scanJob) - val esclRequestClient = ESCLRequestClient( - scanJob.scannerBaseUrl, - httpClientFactory.create(scanJob.httpClientConfig) + val scanningProtocol = scanJob.scannerHandle.protocol + val scanningFlow = scanningProtocol.executeScanJob( + scanJob.scannerHandle, + scanJob.connectionSettings, + scanJob.scanSettings, + scanJobs.shouldCancel ) - scanJobs.notifyStarted(scanJob) - - if (abortIfCancelling()) return + var failed = false - 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") + var pageCounter = 1 - scanJobs.notifyFailed(scanJob, job.toString()) - return - } - val jobResult = job.scanJob + scanningFlow.collect { processingEvent -> + when (processingEvent) { + ScanJobProcessingEvent.Cancelled -> scanJobs.setCancel(false) - if (abortIfCancelling(jobResult)) return + is ScanJobProcessingEvent.Failure -> { + failed = true + scanJobs.notifyFailed(scanJob, processingEvent.error) + return@collect + } - var polling = false + is ScanJobProcessingEvent.NewPage -> { + val pageData = processingEvent.scannedPage - 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") + val scanPageFileName = "scan-" + Uuid.random().toString() + val scanPageFile = File(application.filesDir, scanPageFileName) - val deleteResult = jobResult.cancel() - Timber.d("Cancelling job after (a likely) failure: $deleteResult") + scanPageFile.writeBytes(pageData.data) - 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()) + 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" + ) } - return - } - if (imagesToTransfer != null && imagesToTransfer > 0u) { - Timber.d("There seem to be images to transfer. Breaking out of polling loop") - break - } - delay(1000) - } - } - 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 + "application/pdf" -> { + val extractedImages = extractPdfImages( + scanPageFile.absolutePath, + File(scanPageFile.parent!!) ) - ) - } 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 + extractedImages.forEach { + addScan(scanJob.ownerSessionId, it, scanJob.scanSettings, ScanRelativeRotation.Original) + } } - } - } - - is ESCLRequestClient.ScannerNextPageResult.Success -> { - } - 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 + 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 +250,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..f16d9f7b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/LegacyESCLScanSettings.kt @@ -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.ContentTypeEnumOrRaw +import io.github.chrisimx.esclkt.EsclColorModeEnumOrRaw +import io.github.chrisimx.esclkt.EsclScanIntentEnumOrRaw import io.github.chrisimx.esclkt.FeedDirection import io.github.chrisimx.esclkt.InputSource 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 @@ -28,18 +28,18 @@ data class StatelessImmutableScanRegion( fun toESCLScanRegion(selectedInputSourceCaps: InputSourceCaps): ScanRegion { val height: LengthUnit = when (height) { "max" -> selectedInputSourceCaps.maxHeight - else -> height.toDoubleLocalized().millimeters() + else -> height.toDoubleLocalized()!!.millimeters() } val width: LengthUnit = when (width) { "max" -> selectedInputSourceCaps.maxWidth - else -> width.toDoubleLocalized().millimeters() + else -> width.toDoubleLocalized()!!.millimeters() } return ScanRegion( height.toThreeHundredthsOfInch(), width.toThreeHundredthsOfInch(), - xOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch(), - yOffset.toDoubleLocalized().millimeters().toThreeHundredthsOfInch() + xOffset.toDoubleLocalized()!!.millimeters().toThreeHundredthsOfInch(), + yOffset.toDoubleLocalized()!!.millimeters().toThreeHundredthsOfInch() ) } } @@ -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 deleted file mode 100644 index 5e0d4ecf..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/CustomScannerViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2024-2025 Christian Nagel and contributors - * - * This file is part of ScanBridge. - * - * ScanBridge is free software: you can redistribute it and/or modify it under the terms of - * the GNU General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. - * - * ScanBridge is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with eSCLKt. - * If not, see . - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package io.github.chrisimx.scanbridge.data.ui - -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..13bb94cf 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -19,34 +19,30 @@ 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 com.google.protobuf.LazyStringArrayList.emptyList +import io.github.chrisimx.anyscan.Area +import io.github.chrisimx.anyscan.CommonInputSourceCaps +import io.github.chrisimx.anyscan.CommonInputSourceType +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.ScanRegionValue +import io.github.chrisimx.anyscan.ScanSettingParam +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.SettingValue +import io.github.chrisimx.anyscan.inches +import io.github.chrisimx.anyscan.millimeters +import io.github.chrisimx.scanbridge.PaperFormat +import io.github.chrisimx.scanbridge.PaperFormatProvider import io.github.chrisimx.scanbridge.model.Locale import io.github.chrisimx.scanbridge.model.NumberValidationResult -import io.github.chrisimx.scanbridge.model.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 @@ -55,9 +51,9 @@ 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.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -72,120 +68,128 @@ enum class ScanSettingsLengthUnit { class ScanSettingsComposableStateHolder( @InjectedParam - val scanSettings: StateFlow, + val capabilities: StateFlow, @InjectedParam - private val initialScanSettingsData: ScanSettingsEnterableData, + val scanSettings: StateFlow, @InjectedParam - private val updateSettings: suspend (ScanSettings.() -> ScanSettings) -> Unit, + private val initialScanSettingsData: ScanSettingsEnterableDataV1, + @InjectedParam + private val updateSettings: suspend (CommonScanSettingsEditor.() -> Unit) -> Unit, @InjectedParam private val coroutineScope: CoroutineScope, private val localeProvider: LocaleProvider, - private val context: Application + private val paperFormatProvider: PaperFormatProvider ) { private val _uiState = MutableStateFlow(initialScanSettingsData) - val uiState: StateFlow = _uiState.asStateFlow() + val uiState: StateFlow = _uiState.asStateFlow() + + val inputSourceOptions: StateFlow> = capabilities.derived(coroutineScope) { caps -> + caps.inputSources.map { + it.inputSourceType.toUIInputSourceType() + }.distinct() + } + + val selectedInputSource: StateFlow = scanSettings.derived(coroutineScope) { + it.inputSource?.toUIInputSourceType() ?: inputSourceOptions.value.first() + } - val inputSourceOptions: StateFlow> = _uiState.derived(coroutineScope) { - it.capabilities.getInputSourceOptions() + val duplexUsed: StateFlow = scanSettings.derived(coroutineScope) { + it.inputSource == CommonInputSourceType.ADF_DUPLEX } - val duplexAdfSupported: StateFlow = _uiState.derived(coroutineScope) { - it.capabilities.adf?.duplexCaps != null + val duplexAdfSupported: StateFlow = capabilities.derived(coroutineScope) { caps -> + caps.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, capabilities) { settings, caps -> + caps.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) - } + capabilities.value.inputSources.first() ) - val intentOptions = selectedInputSourceCaps.derived(coroutineScope) { - it.supportedIntents - } + private fun validateDimensionValue(valueString: String, getDimension: (Area) -> LengthUnit): NumberValidationResult { + if (valueString.isBlank()) { + return NumberValidationResult.NotANumber + } - val supportedScanResolutions = selectedInputSourceCaps.derived(coroutineScope) { - it.settingProfiles[0].supportedResolutions - } + val dimension = valueString.toDoubleLocalized() ?: return NumberValidationResult.NotANumber - val supportedColorModes = selectedInputSourceCaps.derived(coroutineScope) { - it.settingProfiles.firstOrNull()?.colorModes ?: listOf() - } + val regionParam = capabilities.value.inputSources.firstOrNull { + it.inputSourceType == scanSettings.value.inputSource + }?.furtherOptions?.get(ScannerConcept.ScanRegion) as? ScanSettingParam.ScanSettingRegionParam - val currentColorMode = scanSettings.derived(coroutineScope) { - it.colorMode - } + val lengthInUnit = when (lengthUnit.value) { + ScanSettingsLengthUnit.INCH -> dimension.inches() + ScanSettingsLengthUnit.MILLIMETER -> dimension.millimeters() + } - val currentResolution: StateFlow = scanSettings.derived(coroutineScope) { - val x = it.xResolution - val y = it.yResolution + if (regionParam == null) { + return NumberValidationResult.Success(lengthInUnit) + } - if (x != null && y != null) DiscreteResolution(x, y) else null - } + val maxDimension = toUserUnit(lengthUnit.value, getDimension(regionParam.maxArea.value)) + val minDimension = toUserUnit(lengthUnit.value, getDimension(regionParam.minArea.value)) - val lengthUnit = localeProvider.locale.derived(coroutineScope) { - unitByLocale(it) + if (dimension !in minDimension..maxDimension) { + return NumberValidationResult.OutOfRange(minDimension, maxDimension) + } else { + return NumberValidationResult.Success(lengthInUnit) + } } - val currentWidthText = _uiState.derived(coroutineScope) { - it.widthString - } + val validationResultHeight: StateFlow = combine(uiState, capabilities) { settings, caps -> + validateDimensionValue(settings.heightString) { it.height } + }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) - val currentHeightText = _uiState.derived(coroutineScope) { - it.heightString - } + val validationResultWidth: StateFlow = combine(uiState, capabilities) { settings, caps -> + validateDimensionValue(settings.widthString) { it.width } + }.stateIn(coroutineScope, SharingStarted.Lazily, NumberValidationResult.NotANumber) - val currentScanRegion = scanSettings.derived(coroutineScope) { - it.scanRegions?.regions?.firstOrNull() + val availableParameters = selectedInputSourceCaps.derived(coroutineScope) { + it.furtherOptions } - 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 availablePaperFormats: StateFlow> = combine( + selectedInputSourceCaps, + paperFormatProvider.formats + ) { inputSourceCaps, paperFormats -> + val regionParam = inputSourceCaps.furtherOptions[ScannerConcept.ScanRegion] as? ScanSettingParam.ScanSettingRegionParam + if (regionParam == null) { + emptyList() + } else { + val maxArea = regionParam.maxArea.value + val minArea = regionParam.minArea.value - val lengthInUnit = when (unit) { - ScanSettingsLengthUnit.INCH -> parsedLength.inches() - ScanSettingsLengthUnit.MILLIMETER -> parsedLength.millimeters() - } + val maxAreaWithTol = maxArea + 0.1.millimeters() + val minAreaWithTol = minArea - 0.1.millimeters() - val inputLengthInT300 = lengthInUnit.toThreeHundredthsOfInch().value + paperFormats + .filter { paperFormat -> + paperFormat.area in maxAreaWithTol && minAreaWithTol in paperFormat.area + } + } + }.stateIn(coroutineScope, SharingStarted.Lazily, emptyList()) - if (inputLengthInT300 in min.value..max.value) { - return NumberValidationResult.Success(inputLengthInT300.toDouble()) - } else { - val maxInUserUnit = toUserUnit(unit, max) - val minInUserUnit = toUserUnit(unit, min) + val lengthUnit = localeProvider.locale.derived(coroutineScope) { + unitByLocale(it) + } - 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,8 +198,24 @@ class ScanSettingsComposableStateHolder( } init { - observeHeightValidation() - observeWidthValidation() + combine(validationResultHeight, validationResultWidth, _uiState) { height, width, ui -> + Triple(height, width, ui.customMenuEnabled) + }.mapNotNull { (height, width, customMenuEnabled) -> + if (customMenuEnabled && height is NumberValidationResult.Success && width is NumberValidationResult.Success) { + height to width + } else { + null + } + }.onEach { (height, width) -> + updateSettings { + this.set( + ScannerConcept.ScanRegion, + ScanRegionValue( + Area(height.value, width.value) + ) + ) + } + }.launchIn(coroutineScope) _uiState .map { it.maximumSize } @@ -204,247 +224,102 @@ class ScanSettingsComposableStateHolder( .filter { it.first } .onEach { (maxSize, inputSourceCaps) -> Timber.d("Maximum size flag set to $maxSize: This means we should set scanRegion to maximum") - updateSettings { - copy( - scanRegions = scanRegion { - width = inputSourceCaps.maxWidth - height = inputSourceCaps.maxHeight - xOffset = 0.millimeters() - yOffset = 0.millimeters() - } - ) + val regionParam = inputSourceCaps + .furtherOptions[ScannerConcept.ScanRegion] as ScanSettingParam.ScanSettingRegionParam? + regionParam?.maxArea?.let { maxArea -> + updateSettings { + set(ScannerConcept.ScanRegion, maxArea) + } } }.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 - } - ) - } - } - } - .launchIn(coroutineScope) - } + fun setDuplex(duplex: Boolean) { + val duplexCurrentlyActive = duplexSettingAvailable.value - 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) - } + if (duplex && !duplexCurrentlyActive) { + Timber.d("Duplex can not be turned on because it is not available. Current duplex state: $duplexCurrentlyActive") + return + } + + val newInputSource = if (duplex) { + CommonInputSourceType.ADF_DUPLEX + } else { + CommonInputSourceType.ADF_SIMPLEX + } - fun setDuplex(duplex: Boolean) { coroutineScope.launch { updateSettings { - copy(duplex = duplex) + setInputSource(inputSource = newInputSource) } } } - fun setColorMode(colorMode: ColorModeEnumOrRaw?) { + fun setInputSource(inputSource: UIInputSourceType) { + Timber.d("Input Source being set to $inputSource") + 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") + updateSettings { + val newInputSource = when (inputSource) { + UIInputSourceType.PLATEN -> CommonInputSourceType.PLATEN + + UIInputSourceType.ADF -> if (this.inputSource == CommonInputSourceType.ADF_DUPLEX) { + CommonInputSourceType.ADF_DUPLEX + } else { + CommonInputSourceType.ADF_SIMPLEX + } } + setInputSource(inputSource = newInputSource) } } } - fun setInputSource(inputSource: InputSource) { - Timber.d("Input Source being set to $inputSource. Validating existing settings.") + fun setSetting(concept: ScannerConcept, value: Any?) { 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 + if (value == null) { + remove(concept) } else { - null + @Suppress("UNCHECKED_CAST") + set(concept, value as T) } - - 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 - ) } } } - fun setResolution(xResolution: UInt, yResolution: UInt) { - coroutineScope.launch { - updateSettings { - copy( - xResolution = xResolution, - yResolution = yResolution - ) - } - } + fun setCustomMenuEnabled(enabled: Boolean) { + _uiState.update { it.copy(customMenuEnabled = enabled) } } - fun setIntent(intent: ScanIntentEnumOrRaw?) { + fun setFormat(paperFormat: PaperFormat) { + val area = Area( + height = paperFormat.height, + width = paperFormat.width + ) + _uiState.update { it.copy(maximumSize = false, customMenuEnabled = false) } + coroutineScope.launch { updateSettings { - copy(intent = intent) + set(ScannerConcept.ScanRegion, ScanRegionValue(area)) } } } - fun setCustomMenuEnabled(enabled: Boolean) { + fun setCustomWidthTextFieldContent(content: String) { _uiState.update { - it.copy( - maximumSize = false, - customMenuEnabled = enabled - ) + it.copy(widthString = content) } } - fun setCustomWidthTextFieldContent(width: String) { - check(_uiState.value.customMenuEnabled) + fun setCustomHeightTextFieldContent(content: String) { _uiState.update { - it.copy( - maximumSize = false, - widthString = width - ) + it.copy(heightString = content) } } - 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 - ) - } - coroutineScope.launch { - updateSettings { - copy( - scanRegions = scanRegion { - width = newWidth - height = newHeight - xOffset = 0.millimeters() - yOffset = 0.millimeters() - } - ) - } + it.copy(maximumSize = true, customMenuEnabled = false) } } - - 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..e9b6b7f3 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -37,41 +37,35 @@ import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image -import io.github.chrisimx.esclkt.ESCLRequestClient -import io.github.chrisimx.esclkt.InputSource -import io.github.chrisimx.esclkt.ScanRegion -import io.github.chrisimx.esclkt.ScanSettings -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.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.ScanSettingsMap +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.inches import io.github.chrisimx.scanbridge.R -import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService import io.github.chrisimx.scanbridge.datastore.appSettingsStore import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.db.entities.Session import io.github.chrisimx.scanbridge.db.entities.TempFile -import io.github.chrisimx.scanbridge.model.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.InitialScanSettingsProvider +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.usecases.StartScanUseCase 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 @@ -80,8 +74,10 @@ import kotlin.io.path.Path import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -104,7 +100,7 @@ enum class ScanningScreenEvent { class ScanningScreenViewModel( @InjectedParam - val address: Url, + val scannerHandle: ScannerHandle, @InjectedParam val timeout: UInt, @InjectedParam @@ -116,7 +112,8 @@ class ScanningScreenViewModel( val db: ScanBridgeDb, application: Application, val scanJobRepo: ScanJobRepository, - val httpClientFactory: HttpClientFactory + val initialScanSettingsProvider: InitialScanSettingsProvider, + val startScanUseCase: StartScanUseCase ) : AndroidViewModel(application) { private val _scanningScreenData = ScanningScreenData( @@ -283,40 +280,57 @@ 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( - caps - ) + val defaultScanSettingsUIData = ScanSettingsEnterableDataV1() if (storedSession != null) { _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( + MutableStateFlow(caps).asStateFlow(), session.map { it?.currentScanSettings ?: storedSession.currentScanSettings } .stateIn(viewModelScope, SharingStarted.Lazily, storedSession.currentScanSettings), - storedSession.currentSettingsUIData?.copy(capabilities = caps) ?: defaultScanSettingsUIData, + storedSession.currentSettingsUIData?.copy() ?: defaultScanSettingsUIData, updateSettings, viewModelScope ) @@ -326,118 +340,30 @@ 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 validatedSettings = savedSettings.copy( - inputSource = validatedInputSource, - duplex = duplex, - intent = intent, - scanRegions = scanRegion - ) - - validatedSettings - } catch (e: Exception) { - Timber.e(e, "Error applying saved settings, using defaults") - caps.calculateDefaultESCLScanSettingsState() - } + val editor = CommonScanSettingsEditor( + caps, + savedSettings + ) + editor.build() } else { - caps.calculateDefaultESCLScanSettingsState() + val editor = CommonScanSettingsEditor( + caps, + CommonScanSettings(setting = ScanSettingsMap.empty()) + ) + initialScanSettingsProvider.applyDefaults(editor, caps) + editor.build() } + val savedSettingsUiStateWithCaps = savedSettingsUiState?.copy() + sessionDao.insertAll(Session(sessionID, initialSettings, savedSettingsUiState)) _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( + MutableStateFlow(caps).asStateFlow(), session.map { it?.currentScanSettings ?: initialSettings } .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), - savedSettingsUiState ?: defaultScanSettingsUIData, + savedSettingsUiStateWithCaps ?: defaultScanSettingsUIData, updateSettings, viewModelScope ) @@ -470,7 +396,7 @@ class ScanningScreenViewModel( val currentSettings = session.value?.currentScanSettings if (currentSettings == null) { - Timber.e("Could not start scan job. Current scan setttings null") + Timber.e("Could not start scan job. Current scan settings null") return@launch } @@ -486,19 +412,12 @@ class ScanningScreenViewModel( return@launch } - val scanJob = ScanJob( - Uuid.generateV4(), - sessionID, - currentSettings, - address, - HttpClientConfig( - certificateValidationDisabled, - withDebugInterceptor, - timeout.toULong() - ) + startScanUseCase.startScan( + ownerSessionId = sessionID, + scannerHandle = scannerHandle, + scanSettings = currentSettings, + connectionSettings = scannerConnectionSettings() ) - scanJobRepo.enqueue(scanJob) - ScanJobForegroundService.startService(application) } } @@ -593,23 +512,22 @@ class ScanningScreenViewModel( PdfDocument(writer).use { pdf -> Document(pdf).use { document -> chunk.forEachIndexed { i, scan -> - val scanRegion = - scan.originalScanSettings.scanRegions?.regions?.first() ?: ScanRegion( - 297.millimeters().toThreeHundredthsOfInch(), - 210.millimeters().toThreeHundredthsOfInch(), - 0.threeHundredthsOfInch(), - 0.threeHundredthsOfInch() - ) - val imageData = ImageDataFactory.create(scan.filePath) val rotated = scan.rotation == ScanRelativeRotation.Rotated - val inputSource = scan.originalScanSettings.inputSource ?: InputSource.Platen + val fallbackInputSourceCaps = scannerCaps.inputSources.first() + + val fallbackResolution = fallbackInputSourceCaps + .furtherOptions[ScannerConcept.ScanResolution]!! + .defaultValue - val fallbackResolution = scannerCaps.getMaxResolution(inputSource) - val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution - val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution + val originalSettingsMap = scan.originalScanSettings.setting + + val scanResolution = originalSettingsMap[ScannerConcept.ScanResolution] ?: fallbackResolution + + val scannerXResolution = scanResolution.value.widthDPI + val scannerYResolution = scanResolution.value.heightDPI val rotationCorrectedXRes = if (rotated) scannerYResolution else scannerXResolution val rotationCorrectedYRes = if (rotated) scannerXResolution else scannerYResolution @@ -725,6 +643,7 @@ class ScanningScreenViewModel( zipOutputFile, { counter++ + // TODO: This will never work correctly because extension is always empty "scan-${counter.toString().padStart(digitsNeeded, '0')}.${it.extension}" } ) @@ -752,29 +671,24 @@ class ScanningScreenViewModel( } } - fun createHttpClientConfig() = HttpClientConfig( + fun scannerConnectionSettings() = 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 = scannerConnectionSettings() - 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/scan/AndroidStartScanUseCase.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt new file mode 100644 index 00000000..e100d31b --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt @@ -0,0 +1,53 @@ +package io.github.chrisimx.scanbridge.scan + +import android.app.Application +import io.github.chrisimx.anyscan.ColorMode +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.FileFormat +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.enumorrawcodegen.asEnumOrRaw +import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService +import io.github.chrisimx.scanbridge.model.ScanJob +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.services.ScanJobRepository +import io.github.chrisimx.scanbridge.usecases.StartScanUseCase +import kotlin.uuid.Uuid + +class AndroidStartScanUseCase(val scanJobRepo: ScanJobRepository, val application: Application) : StartScanUseCase { + override fun startScan( + ownerSessionId: Uuid, + scannerHandle: ScannerHandle, + scanSettings: CommonScanSettings, + connectionSettings: ScannerConnectionSettings + ) { + val scanJob = ScanJob( + Uuid.generateV4(), + ownerSessionId, + scanSettings.applyFileFormat(), + scannerHandle, + connectionSettings + ) + + scanJobRepo.enqueue(scanJob) + ScanJobForegroundService.startService(application) + } + + /** + * Decides the correct file format for the scan and returns the modified [CommonScanSettings] + * with this file format. + * + * @return Modified scan settings with the correct file format selected + */ + private fun CommonScanSettings.applyFileFormat(): CommonScanSettings { + // TODO: Should also be in the scan use case. Not here + val isBlackAndWhite = this.setting[ScannerConcept.ColorMode] + ?.value == ColorMode.BlackAndWhite1.toString() + + val format = if (isBlackAndWhite) FileFormat.PDF else FileFormat.JPEG + + return this.copy( + format = format.asEnumOrRaw() + ) + } +} diff --git a/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..f48d5bc3 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt @@ -33,24 +33,57 @@ 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 +95,14 @@ 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 +111,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 +161,7 @@ fun FoundScannerItem( ) } Text( - address, + "$scannerHandleString ($protocolIdentifier)", style = MaterialTheme.typography.labelLarge ) } @@ -102,7 +170,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 +183,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/uicomponents/dialog/CustomScannerDialog.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index 5d01d3f1..a4089256 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt @@ -1,5 +1,6 @@ package io.github.chrisimx.scanbridge.uicomponents.dialog +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -10,6 +11,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -21,17 +23,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.data.model.EditedCustomScanner +import io.github.chrisimx.scanbridge.scannerdiscovery.ProtocolWithExampleHandleString +import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme +import io.github.chrisimx.scanbridge.uicomponents.SelectionButtonRow import io.ktor.http.Url +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.scanning_protocol @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CustomScannerDialog( + protocolsWithExampleHandle: List, onDismiss: () -> Unit, - onConnectClicked: (name: String, url: Url, save: Boolean, navigate: Boolean) -> Unit, + onConnectClicked: (name: String, url: Url, protocol: String, save: Boolean, navigate: Boolean) -> Unit, editingType: EditedCustomScanner ) { var urlErrorState: String? by remember { mutableStateOf(null) } @@ -39,15 +49,23 @@ fun CustomScannerDialog( is EditedCustomScanner.EditingOld -> editingType.scanner EditedCustomScanner.New -> null } + val originalIdentifier = protocolsWithExampleHandle + .firstOrNull { it.protocolIdentifier == initializeWith?.protocolIdentifier } + var urlText: String by remember { mutableStateOf(initializeWith?.url?.toString() ?: "") } var nameText: String by remember { mutableStateOf(initializeWith?.name ?: "") } + var selectedProtocol by remember { + mutableStateOf(originalIdentifier ?: protocolsWithExampleHandle.first()) + } + val context = LocalContext.current val isNewScanner = editingType is EditedCustomScanner.New + // TODO: Move validation to view model val validateUrl = fun(): Url? { if (urlText.isEmpty()) { urlErrorState = context.getString(R.string.error_state_please_enter_an_url) @@ -56,7 +74,7 @@ fun CustomScannerDialog( try { return Url(urlText) - } catch (_: IllegalArgumentException) { + } catch (_: Exception) { urlErrorState = context.getString(R.string.invalid_url) return null } @@ -103,8 +121,8 @@ fun CustomScannerDialog( urlErrorState = null urlText = it }, - label = { Text(stringResource(R.string.url_escl_resource)) }, - placeholder = { Text("http://192.168.178.2/eSCL/") }, + label = { Text(stringResource(R.string.custom_scanner_url)) }, + placeholder = { Text(selectedProtocol.exampleScannerIdentifierString) }, supportingText = { urlErrorState?.let { Text( @@ -116,11 +134,25 @@ fun CustomScannerDialog( } ) + SelectionButtonRow( + stringResource(Res.string.scanning_protocol), + protocolsWithExampleHandle, + { selectedProtocol = it!! }, + { this.protocolIdentifier }, + selectedProtocol + ) + if (isNewScanner) { Button( onClick = { val url = validateUrl() ?: return@Button - onConnectClicked(nameText, url, true, true) + onConnectClicked( + nameText, + url, + selectedProtocol.protocolIdentifier, + true, + true + ) }, modifier = Modifier.padding(top = 16.dp) ) { @@ -129,7 +161,13 @@ fun CustomScannerDialog( Button( onClick = { val url = validateUrl() ?: return@Button - onConnectClicked(nameText, url, false, true) + onConnectClicked( + nameText, + url, + selectedProtocol.protocolIdentifier, + false, + true + ) }, modifier = Modifier.padding(top = 8.dp).testTag("justconnect") ) { @@ -139,9 +177,15 @@ fun CustomScannerDialog( Button( onClick = { val url = validateUrl() ?: return@Button - onConnectClicked(nameText, url, true, false) + onConnectClicked( + nameText, + url, + selectedProtocol.protocolIdentifier, + true, + false + ) }, - modifier = Modifier.padding(top = 0.dp).testTag("editcustomscanner") + modifier = Modifier.padding(top = 16.dp).testTag("editcustomscanner") ) { Text(stringResource(R.string.save)) } @@ -150,3 +194,22 @@ fun CustomScannerDialog( } } } + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@Preview +@Composable +fun PreviewCustomScannerDialog() { + Scaffold { + ScanBridgeTheme { + CustomScannerDialog( + listOf( + ProtocolWithExampleHandleString("test", "test") + ), + {}, + { _, _, _, _, _ -> Unit }, + EditedCustomScanner.New + + ) + } + } +} 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..1368d1ad 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -21,19 +21,9 @@ 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.esclkt.DiscreteResolution -import io.github.chrisimx.esclkt.EnumOrRaw -import io.github.chrisimx.esclkt.InputSource +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource 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 fun JobState?.toJobStateString(context: Context): String = when (this) { @@ -45,70 +35,30 @@ fun JobState?.toJobStateString(context: Context): String = when (this) { null -> context.getString(R.string.job_state_cannot_be_retrieved) } -fun String.toDoubleLocalized(): Double = DecimalFormat.getInstance().parse(this).toDouble() +fun String.toDoubleLocalized(): Double? = runCatching { + DecimalFormat.getInstance().parse(this).toDouble() +}.getOrNull() fun Double.toStringLocalized(): String = DecimalFormat.getInstance().format(this) -fun 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 ColorModeEnumOrRaw.localizedString(context: Context): String = when (this) { - is EnumOrRaw.Known -> when (this.value) { - ColorMode.BlackAndWhite1 -> context.getString(R.string.black_and_white) - ColorMode.RGB24 -> context.getString(FormattedResources.color_scan("24")) - ColorMode.RGB48 -> context.getString(FormattedResources.color_scan("48")) - ColorMode.AutoColorDetection -> context.getString(R.string.auto_detect) - ColorMode.Grayscale8 -> context.getString(FormattedResources.grayscale("8")) - ColorMode.Grayscale16 -> context.getString(FormattedResources.grayscale("16")) - } - - is EnumOrRaw.Unknown -> this.asString() -} - -fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteResolution { - val inputCaps = this.getInputSourceCaps(inputSource) - val maxResolution = inputCaps - .settingProfiles.first() - .supportedResolutions.discreteResolutions.maxBy { it.xResolution * it.yResolution } - - return maxResolution -} +@Composable +fun UIInputSourceType.toReadableString(): String = when (this) { + UIInputSourceType.PLATEN -> + stringResource(R.string.platen) -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 + UIInputSourceType.ADF -> stringResource(R.string.adf) } -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" - ) +/** + * 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? = if (list.contains(this)) { + this +} else { + list.firstOrNull() } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt index 8f316a23..f1d77c42 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt @@ -3,8 +3,7 @@ package io.github.chrisimx.scanbridge.util import java.io.File fun File.getEditedImageName(): String { - val baseName = this.nameWithoutExtension - val extension = this.extension + val baseName = this.name.substringBefore(" edit-") - return "$baseName edit-${System.currentTimeMillis()}.$extension" + return "$baseName edit-${System.currentTimeMillis()}" } 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..2ffa085f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt @@ -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,21 @@ 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 +70,22 @@ fun snackbarErrorRetrievingPage( ) } +suspend fun SnackbarHostState.showCustomSnackbar( + message: String, + type: SnackbarType = SnackbarType.DEFAULT, + actionLabel: String? = null, + duration: SnackbarDuration = SnackbarDuration.Short, + withDismissAction: Boolean = false +): SnackbarResult = showSnackbar( + CustomSnackbarVisuals( + message = message, + actionLabel = actionLabel, + duration = duration, + withDismissAction = withDismissAction, + type = type + ) +) + fun snackBarError( error: String, scope: CoroutineScope, @@ -61,9 +95,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/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt b/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt index 35ceeff9..1af2766d 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt @@ -10,29 +10,27 @@ import androidx.compose.material3.ExperimentalMaterial3Api @OptIn(ExperimentalMaterial3Api::class) val STARTUP_TABS = listOf( StartupScreen( - io.github.chrisimx.scanbridge.R.string.discovery, - io.github.chrisimx.scanbridge.R.string.header_scannerbrowser, + R.string.discovery, + R.string.header_scannerbrowser, Icons.Filled.Home, Icons.Outlined.Home, true, - { innerPadding, navController, showCustomDialog, setShowCustomDialog, statefulScannerMap, statefulScannerMapSecure -> + { innerPadding, navController, showCustomDialog, setShowCustomDialog -> ScannerBrowser( innerPadding, navController, showCustomDialog, - setShowCustomDialog, - statefulScannerMap, - statefulScannerMapSecure + setShowCustomDialog ) } ), StartupScreen( - io.github.chrisimx.scanbridge.R.string.settings, - io.github.chrisimx.scanbridge.R.string.settings, + R.string.settings, + R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false, - { innerPadding, _, _, _, _, _ -> + { innerPadding, _, _, _ -> AppSettingsScreen(innerPadding) } ), @@ -42,7 +40,7 @@ val STARTUP_TABS = listOf( BaselineHelp24, OutlineHelp24, false, - { innerPadding, _, _, _, _, _ -> + { innerPadding, _, _, _ -> SupportScreen(innerPadding) } ) diff --git a/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index a7dc95b6..f7881737 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -11,7 +11,7 @@ plugins { kotlin { jvm { - compilerOptions { jvmTarget = JvmTarget.JVM_17 } + compilerOptions { jvmTarget = JvmTarget.JVM_21 } } android { @@ -22,7 +22,7 @@ kotlin { compilerOptions { jvmTarget.set( - org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 ) } } @@ -35,6 +35,9 @@ kotlin { api(libs.compose.resources) api(libs.compose.ui.tooling.preview) api(libs.compose.material3) + + api(libs.anyscanCore) + api(project(":core")) } commonTest.dependencies { diff --git a/androidApp/src/main/res/values-de/strings.xml b/composeUI/src/androidMain/res/values-de/strings.xml similarity index 97% rename from androidApp/src/main/res/values-de/strings.xml rename to composeUI/src/androidMain/res/values-de/strings.xml index a56588bb..9d063ed9 100644 --- a/androidApp/src/main/res/values-de/strings.xml +++ b/composeUI/src/androidMain/res/values-de/strings.xml @@ -66,7 +66,7 @@ Wird gedreht… Auf einen benutzdefinierten Scanner zugreifen Verbinden - URL (eSCL-Ressource) + URL Verbinden mit benutzerdefinierten Scanner Ungültige URL Gib bitte eine URL ein @@ -117,9 +117,11 @@ Gescannte Seiten werden im Hintergrund abgerufen. Farbmodus: Schwarz-Weiß - Farbe ({bitdepth} bit) + Farbe (24 bit) + Farbe (48 bit) Automatische Erkennung - Graustufen ({bitdepth} bit) + Graustufen (8 bit) + Graustufen (16 bit) Speichern Benutzerdefinierten Scanner bearbeiten Willst du den benutzerdefinierten Scanner wirklich löschen? Dieser Vorgang ist unwiderruflich. diff --git a/androidApp/src/main/res/values-it/strings.xml b/composeUI/src/androidMain/res/values-it/strings.xml similarity index 97% rename from androidApp/src/main/res/values-it/strings.xml rename to composeUI/src/androidMain/res/values-it/strings.xml index 7ebb1aa4..d80dceca 100644 --- a/androidApp/src/main/res/values-it/strings.xml +++ b/composeUI/src/androidMain/res/values-it/strings.xml @@ -64,7 +64,7 @@ Rotazione in corso… Accedi allo scanner personalizzato Connettiti - URL (risorsa eSCL) + URL Connettiti a uno scanner personalizzato URL non valido Per favore inserisci un URL @@ -116,11 +116,13 @@ Le pagine scansionate vengono recuperate in background. Modalità colore: Bianco e nero - Colore ({bitdepth} bit) + Colore (48 bit) Rilevamento automatico - Scala di grigi ({bitdepth} bit) + Scala di grigi (16 bit) Salva Modifica scanner personalizzato Vuoi davvero eliminare lo scanner personalizzato? Questa operazione è irreversibile. Elimina scanner personalizzato + Colore (24 bit) + Scala di grigi (8 bit) \ No newline at end of file diff --git a/androidApp/src/main/res/values/strings.xml b/composeUI/src/androidMain/res/values/strings.xml similarity index 94% rename from androidApp/src/main/res/values/strings.xml rename to composeUI/src/androidMain/res/values/strings.xml index 1d72524c..a06733db 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/composeUI/src/androidMain/res/values/strings.xml @@ -66,7 +66,7 @@ Rotating… Access custom scanner Just connect - URL (eSCL resource) + URL Connect to a custom scanner Invalid URL Please enter an URL @@ -119,11 +119,19 @@ Scan job running… Color Mode: - Color ({bitdepth} bit) + Color (24 bit) + Color (48 bit) Automatic detection - Grayscale ({bitdepth} bit) + Grayscale (8 bit) + Grayscale (16 bit) Save Edit custom scanner Do you really want to delete this custom scanner? This action cannot be undone. Delete custom scanner + Document + + Photo + Preview + 3D Object + Business Card \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index a56588bb..c0c6bd0b 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -117,11 +117,20 @@ Gescannte Seiten werden im Hintergrund abgerufen. Farbmodus: Schwarz-Weiß - Farbe ({bitdepth} bit) + Farbe (24 bit) + Farbe (48 bit) Automatische Erkennung - Graustufen ({bitdepth} bit) + Graustufen (8 bit) + Graustufen (16 bit) Speichern Benutzerdefinierten Scanner bearbeiten Willst du den benutzerdefinierten Scanner wirklich löschen? Dieser Vorgang ist unwiderruflich. Benutzerdefinierten Scanner löschen + Dokument + + Foto + Vorschau + 3D-Objekt + Visitenkarte + Protokoll \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/values-it/strings.xml b/composeUI/src/commonMain/composeResources/values-it/strings.xml index 7ebb1aa4..d3ba5551 100644 --- a/composeUI/src/commonMain/composeResources/values-it/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-it/strings.xml @@ -116,11 +116,13 @@ Le pagine scansionate vengono recuperate in background. Modalità colore: Bianco e nero - Colore ({bitdepth} bit) + Colore (48 bit) Rilevamento automatico - Scala di grigi ({bitdepth} bit) + Scala di grigi (16 bit) Salva Modifica scanner personalizzato Vuoi davvero eliminare lo scanner personalizzato? Questa operazione è irreversibile. Elimina scanner personalizzato + Colore (24 bit) + Scala di grigi (8 bit) \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/values/strings.xml b/composeUI/src/commonMain/composeResources/values/strings.xml index 1d72524c..d22034a8 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -119,11 +119,20 @@ Scan job running… Color Mode: - Color ({bitdepth} bit) + Color (24 bit) + Color (48 bit) Automatic detection - Grayscale ({bitdepth} bit) + Grayscale (8 bit) + Grayscale (16 bit) Save Edit custom scanner Do you really want to delete this custom scanner? This action cannot be undone. Delete custom scanner + Document + + Photo + Preview + 3D Object + Business Card + Protocol \ No newline at end of file diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt new file mode 100644 index 00000000..fce7a540 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt @@ -0,0 +1,40 @@ +package io.github.chrisimx.scanbridge.uicomponents + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign + +@Composable +fun SelectionButtonRow(title: String, options: List, onSet: (T?) -> Unit, stringify: @Composable T.() -> String, value: T?) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + title, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + SingleChoiceSegmentedButtonRow { + options.forEachIndexed { index, option -> + val name = option.stringify() + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = options.size + ), + onClick = { + onSet(option) + }, + selected = option == value + ) { + Text(name) + } + } + } + } +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt new file mode 100644 index 00000000..5179b928 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionCard.kt @@ -0,0 +1,68 @@ +package io.github.chrisimx.scanbridge.uicomponents + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.InputChip +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.default_string + +@Composable +fun SelectionCard( + title: String, + options: List, + onSet: (T?) -> Unit, + stringify: @Composable T.() -> String, + value: T?, + isSmallRowAbove: Boolean = false, + hasDefaultOption: Boolean = false +) { + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = if (isSmallRowAbove) 30.dp else 15.dp, bottom = 15.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + title, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + FlowRow( + Modifier.fillMaxWidth(), + + horizontalArrangement = Arrangement.SpaceEvenly + ) { + options.forEach { option -> + val name = option.stringify() + InputChip( + onClick = { + onSet(option) + }, + label = { Text(name) }, + selected = value == option + ) + } + if (hasDefaultOption) { + InputChip( + onClick = { + onSet(null) + }, + label = { Text(stringResource(Res.string.default_string)) }, + selected = value == null + ) + } + } + } + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt similarity index 74% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt index 89a869ca..06f26cdd 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ValidatedTextField.kt @@ -19,7 +19,6 @@ package io.github.chrisimx.scanbridge.uicomponents -import android.content.Context import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -27,19 +26,23 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType -import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.model.NumberValidationResult +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.error_state_not_a_valid_number +import scanbridge.composeui.generated.resources.error_state_not_in_allowed_range +import scanbridge.composeui.generated.resources.error_state_valid -fun NumberValidationResult.toHumanString(context: Context): String = when (this) { - is NumberValidationResult.OutOfRange -> context.getString(R.string.error_state_not_in_allowed_range) - NumberValidationResult.NotANumber -> context.getString(R.string.error_state_not_a_valid_number) - is NumberValidationResult.Success -> context.getString(R.string.error_state_valid) +@Composable +fun NumberValidationResult.toHumanString(): String = when (this) { + is NumberValidationResult.OutOfRange -> stringResource(Res.string.error_state_not_in_allowed_range) + NumberValidationResult.NotANumber -> stringResource(Res.string.error_state_not_a_valid_number) + is NumberValidationResult.Success -> stringResource(Res.string.error_state_valid) } @Composable fun ValidatedDimensionsTextEdit( text: String, - context: Context, modifier: Modifier = Modifier, label: String, updateContent: (String) -> Unit, @@ -54,9 +57,7 @@ fun ValidatedDimensionsTextEdit( supportingText = { if (validationResult !is NumberValidationResult.Success) { Text( - validationResult.toHumanString( - context - ), + validationResult.toHumanString(), style = MaterialTheme.typography.labelSmall ) } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt new file mode 100644 index 00000000..8843a48b --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt @@ -0,0 +1,78 @@ +package io.github.chrisimx.scanbridge.util + +import androidx.compose.runtime.Composable +import io.github.chrisimx.anyscan.ColorMode +import io.github.chrisimx.anyscan.DiscreteResolutionValue +import io.github.chrisimx.anyscan.ScanIntent +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.SettingValue +import io.github.chrisimx.anyscan.StringValue +import io.github.chrisimx.anyscan.toAnyScanEnumOrRaw +import io.github.chrisimx.enumorrawcodegen.AnyScanEnumOrRaw +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.* +import scanbridge.composeui.generated.resources.Res + +@JvmName("localizedColorModeString") +@Composable +fun AnyScanEnumOrRaw.localizedString(): String = when (this) { + is AnyScanEnumOrRaw.Known -> stringResource( + when (this.value) { + ColorMode.BlackAndWhite1 -> Res.string.black_and_white + ColorMode.RGB24 -> Res.string.color_scan_24 + ColorMode.RGB48 -> Res.string.color_scan_48 + ColorMode.AutoColorDetection -> Res.string.auto_detect + ColorMode.Grayscale8 -> Res.string.grayscale_8 + ColorMode.Grayscale16 -> Res.string.grayscale_16 + } + ) + + is AnyScanEnumOrRaw.Unknown -> this.asString() +} + +@JvmName("localizedScanIntentString") // Else the declaration would clash on JVM because of type erasure +@Composable +fun AnyScanEnumOrRaw.localizedString(): String = when (this) { + is AnyScanEnumOrRaw.Known -> stringResource( + when (this.value) { + ScanIntent.Document -> Res.string.scan_intent_document + ScanIntent.TextAndGraphic -> Res.string.scan_intent_text_and_graphic + ScanIntent.Photo -> Res.string.scan_intent_photo + ScanIntent.Preview -> Res.string.scan_intent_preview + ScanIntent.Object -> Res.string.scan_intent_object + ScanIntent.BusinessCard -> Res.string.scan_intent_business_card + } + ) + + is AnyScanEnumOrRaw.Unknown -> this.asString() +} + +@Composable +fun StringValue.toLocalizedName(scannerConcept: ScannerConcept): String = when (scannerConcept) { + ScannerConcept.ColorMode -> { + this.value + .toAnyScanEnumOrRaw() + .localizedString() + } + + ScannerConcept.ScanIntent -> { + this.value + .toAnyScanEnumOrRaw() + .localizedString() + } + + else -> this.toString() +} + +@Composable +fun SettingValue.toLocalizedName(scannerConcept: ScannerConcept): String = when (this) { + is DiscreteResolutionValue -> if (this.value.widthDPI == this.value.heightDPI) { + "${this.value.widthDPI}" + } else { + "${this.value.widthDPI}x${this.value.heightDPI}" + } + + is StringValue -> this.toLocalizedName(scannerConcept) + + else -> this.toString() +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt new file mode 100644 index 00000000..43c0cd74 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt @@ -0,0 +1,18 @@ +package io.github.chrisimx.scanbridge.util + +import androidx.compose.runtime.Composable +import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.SettingValue +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.* +import scanbridge.composeui.generated.resources.Res + +@Composable +fun ScannerConcept.toLocalizedName(): String = stringResource( + when (this) { + ScannerConcept.ColorMode -> Res.string.color_mode + ScannerConcept.ScanIntent -> Res.string.intent + ScannerConcept.ScanRegion -> Res.string.scan_region + ScannerConcept.ScanResolution -> Res.string.resolution_dpi + } +) diff --git a/core/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..c51159db --- /dev/null +++ b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/5.json @@ -0,0 +1,268 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "057d1a9308ba437a28a881e7a441cfbf", + "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, 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" + } + ], + "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, '057d1a9308ba437a28a881e7a441cfbf')" + ] + } +} \ No newline at end of file diff --git a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceTest.kt b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt similarity index 92% rename from core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceTest.kt rename to core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt index a4473fdf..dbd38967 100644 --- a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceTest.kt +++ b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt @@ -117,10 +117,7 @@ class AndroidMdnsDiscoverServiceInstrumentedTest { continuation.resume(this) } - override fun onRegistrationFailed( - serviceInfo: NsdServiceInfo, - errorCode: Int - ) { + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { continuation.resumeWithException( AssertionError("NSD registration failed: $errorCode") ) @@ -128,10 +125,7 @@ class AndroidMdnsDiscoverServiceInstrumentedTest { override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) = Unit - override fun onUnregistrationFailed( - serviceInfo: NsdServiceInfo, - errorCode: Int - ) = Unit + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit } nsdManager.registerService( @@ -148,9 +142,7 @@ class AndroidMdnsDiscoverServiceInstrumentedTest { } } - private fun unregisterTestService( - listener: NsdManager.RegistrationListener - ) { + private fun unregisterTestService(listener: NsdManager.RegistrationListener) { runCatching { nsdManager.unregisterService(listener) } diff --git a/core/src/androidMain/kotlin/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/AndroidMdnsDiscoverService.kt b/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt index 7507bc0e..8809a07b 100644 --- a/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt +++ b/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt @@ -15,14 +15,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -class AndroidMdnsDiscoverService( - val appContext: Context, - val loggerFactory: ScanBridgeLoggerFactory -) : MdnsDiscoverService { +class AndroidMdnsDiscoverService(val appContext: Context, val loggerFactory: ScanBridgeLoggerFactory) : MdnsDiscoverService { private val logger = loggerFactory.withClass(this::class) - private val _registeredListeners = mutableListOf() - private val _serviceInfoCallbacks = + private val registeredListeners = mutableListOf() + private val serviceInfoCallbacks = mutableListOf() override val foundServices: StateFlow> @@ -34,12 +31,12 @@ class AndroidMdnsDiscoverService( private val started = AtomicBoolean(false) + private var _serviceType: String? = null + override val serviceType: String? get() = _serviceType - var _serviceType: String? = null - - //private val callbackExecutor = Executors.newSingleThreadExecutor() + // private val callbackExecutor = Executors.newSingleThreadExecutor() override fun start(serviceType: String) { if (started.getAndSet(true)) { @@ -70,22 +67,22 @@ class AndroidMdnsDiscoverService( _serviceType = null - for (listener in _registeredListeners) { + for (listener in registeredListeners) { nsdManager.stopServiceDiscovery(listener) } - for (callback in _serviceInfoCallbacks) { + for (callback in serviceInfoCallbacks) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7) { nsdManager.unregisterServiceInfoCallback(callback) } } - _serviceInfoCallbacks.clear() - _registeredListeners.clear() + serviceInfoCallbacks.clear() + registeredListeners.clear() } private fun createDiscoveryListener(): NsdManager.DiscoveryListener { - val discoveryListener = object : NsdManager.DiscoveryListener { + val discoveryListener = object : NsdManager.DiscoveryListener { override fun onDiscoveryStarted(serviceType: String) { logger.info { "Service discovery started: $serviceType" } } @@ -102,7 +99,9 @@ class AndroidMdnsDiscoverService( logger.info { "Service with name ${serviceInfo.serviceName} and type ${serviceInfo.serviceType} found" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7 + ) { val serviceInfoCallback = createServiceInfoCallback(serviceInfo) nsdManager.registerServiceInfoCallback(serviceInfo, ForkJoinPool(1), serviceInfoCallback) } else { @@ -136,7 +135,7 @@ class AndroidMdnsDiscoverService( nsdManager.stopServiceDiscovery(this) } } - _registeredListeners.add(discoveryListener) + registeredListeners.add(discoveryListener) return discoveryListener } @@ -163,16 +162,13 @@ class AndroidMdnsDiscoverService( } } - private fun nsdServiceInfoToMdnsService(serviceInfo: NsdServiceInfo): MdnsService { - - return MdnsService( - serviceInfo.serviceName, - serviceInfo.serviceType, - serviceInfo.port, - getAddressesOfNsdService(serviceInfo), - serviceInfo.attributes - ) - } + private fun nsdServiceInfoToMdnsService(serviceInfo: NsdServiceInfo): MdnsService = MdnsService( + serviceInfo.serviceName, + serviceInfo.serviceType, + serviceInfo.port, + getAddressesOfNsdService(serviceInfo), + serviceInfo.attributes + ) private fun getAddressesOfNsdService(serviceInfo: NsdServiceInfo): List { val inetAddresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -184,13 +180,11 @@ class AndroidMdnsDiscoverService( return inetAddresses.map { it.toMultiplatformIpAddress() } } - private fun getServiceUniqueIdentifier(serviceInfo: NsdServiceInfo): String { - return "${serviceInfo.serviceName}.${serviceInfo.serviceType}" - } + private fun getServiceUniqueIdentifier(serviceInfo: NsdServiceInfo): String = "${serviceInfo.serviceName}.${serviceInfo.serviceType}" @RequiresExtension(extension = Build.VERSION_CODES.TIRAMISU, version = 7) private fun createServiceInfoCallback(originalServiceInfo: NsdServiceInfo): NsdManager.ServiceInfoCallback { - val serviceInfoCallback = object : NsdManager.ServiceInfoCallback { + val serviceInfoCallback = object : NsdManager.ServiceInfoCallback { override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { logger.error { "ServiceInfoCallback (${this.hashCode()}) registration failed: $errorCode" } } @@ -202,7 +196,7 @@ class AndroidMdnsDiscoverService( override fun onServiceLost() { val serviceIdentifier = getServiceUniqueIdentifier(originalServiceInfo) - _serviceInfoCallbacks.remove(this) + serviceInfoCallbacks.remove(this) nsdManager.unregisterServiceInfoCallback(this) _foundServices.update { val updateMap = it.toMutableMap() @@ -222,14 +216,11 @@ class AndroidMdnsDiscoverService( updateServiceStore(serviceIdentifier, mdnsService) } } - _serviceInfoCallbacks.add(serviceInfoCallback) + serviceInfoCallbacks.add(serviceInfoCallback) return serviceInfoCallback } - private fun updateServiceStore( - serviceIdentifier: String, - mdnsService: MdnsService - ) { + private fun updateServiceStore(serviceIdentifier: String, mdnsService: MdnsService) { _foundServices.update { it + (serviceIdentifier to mdnsService) } @@ -237,6 +228,6 @@ class AndroidMdnsDiscoverService( override fun close() { stop() - //callbackExecutor.shutdown() + // callbackExecutor.shutdown() } } diff --git a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt b/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt new file mode 100644 index 00000000..f21ca143 --- /dev/null +++ b/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt @@ -0,0 +1,29 @@ +import android.app.Application +import android.content.Context +import android.net.wifi.WifiManager +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler + +class AndroidMulticastLockHandler(val application: Application, private val loggerFactory: ScanBridgeLoggerFactory) : MulticastLockHandler { + + private val logger = loggerFactory.withClass(this::class) + private val wifiManager = + application.getSystemService(Context.WIFI_SERVICE) as WifiManager + + private val lock = + wifiManager.createMulticastLock("wsd_discovery") + + override fun acquire() { + if (!lock.isHeld) { + logger.debug { "Acquiring multicast lock" } + lock.acquire() + } + } + + override fun release() { + if (lock.isHeld) { + logger.debug { "Releasing multicast lock" } + lock.release() + } + } +} diff --git a/core/src/androidMain/kotlin/InetAddressKMPAdapter.kt b/core/src/androidMain/kotlin/InetAddressKMPConverter.kt similarity index 95% rename from core/src/androidMain/kotlin/InetAddressKMPAdapter.kt rename to core/src/androidMain/kotlin/InetAddressKMPConverter.kt index a5c2755a..350ffdfe 100644 --- a/core/src/androidMain/kotlin/InetAddressKMPAdapter.kt +++ b/core/src/androidMain/kotlin/InetAddressKMPConverter.kt @@ -6,7 +6,7 @@ fun InetAddress.toMultiplatformIpAddress(): IpAddress { return when (raw.size) { 4 -> IpAddress.V4( - bytes = raw, + bytes = raw ) 16 -> IpAddress.V6( diff --git a/core/src/androidMain/kotlin/TimingEventListener.kt b/core/src/androidMain/kotlin/TimingEventListener.kt new file mode 100644 index 00000000..55dec8ad --- /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..2f6c65fe 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt @@ -1,12 +1,17 @@ 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.Area +import io.github.chrisimx.anyscan.LengthUnit +import io.github.chrisimx.anyscan.Millimeters +import io.github.chrisimx.anyscan.millimeters import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient @Serializable -data class PaperFormat(val name: String, val width: LengthUnit, val height: LengthUnit) +data class PaperFormat(val name: String, val width: LengthUnit, val height: LengthUnit) { + @Transient + val area = Area(height, width) +} fun loadDefaultFormats(): List { val defaultPaperFormats: MutableList = mutableListOf() diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt new file mode 100644 index 00000000..c01a041d --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt @@ -0,0 +1,13 @@ +package io.github.chrisimx.scanbridge + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +interface PaperFormatProvider { + val formats: StateFlow> +} + +class DefaultPaperFormatProvider : PaperFormatProvider { + override val formats: StateFlow> = MutableStateFlow(loadDefaultFormats()).asStateFlow() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt new file mode 100644 index 00000000..a6ea6a01 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt @@ -0,0 +1,13 @@ +package io.github.chrisimx.scanbridge + +import io.github.chrisimx.scanbridge.escl.EsclScanningProtocol +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.wsd.WsdScanningProtocol +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val scanProtocols = module { + single() bind ScanningProtocol::class + single() bind ScanningProtocol::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt index 7a320eac..ab8e8ee0 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsJson.kt @@ -1,26 +1,13 @@ 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 kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic -import kotlinx.serialization.modules.subclass object ScanSettingsJson { val json = Json { ignoreUnknownKeys = false - serializersModule = SerializersModule { - polymorphic(LengthUnit::class) { - subclass(Inches::class) - subclass(Millimeters::class) - subclass(ThreeHundredthsOfInch::class) - subclass(Points::class) - } - } + serializersModule = SerializersModule {} + 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..3904c522 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt @@ -0,0 +1,34 @@ +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 = scanningProtocols + + override fun getDiscoveryBackends( + coroutineScope: CoroutineScope, + connectionSettings: ScannerConnectionSettings + ): List = scanningProtocols.mapNotNull { + it.createDiscoveryBackend(coroutineScope, connectionSettings) + } + + override fun getProtocolFromIdentifier(identifier: String): ScanningProtocol? = 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..d130dfdf --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt @@ -0,0 +1,22 @@ +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? = 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..680f24f6 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt @@ -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/ScanBridgeDbFactory.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt index 5980bea5..d12fc57d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt @@ -1,5 +1,6 @@ package io.github.chrisimx.scanbridge.db +import MIGRATION_4_5 import androidx.sqlite.driver.bundled.BundledSQLiteDriver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -14,6 +15,7 @@ class DefaultScanBridgeDbFactory(val builderFactory: ScanBridgeDbBuilderFactory) return dbBuilder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .addMigrations(MIGRATION_4_5) .build() } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/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..6f7b874c 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/ScannedPage.kt @@ -4,7 +4,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.model.ScanRelativeRotation import kotlin.uuid.Uuid @@ -31,7 +31,8 @@ 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, + 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/migrations/Version4To5.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt new file mode 100644 index 00000000..e0bf8606 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/Version4To5.kt @@ -0,0 +1,118 @@ +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.scanbridge.ScanSettingsJson +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 + +val MIGRATION_4_5 = object : Migration(4, 5) { + + override fun migrate(connection: SQLiteConnection) { + migrateCustomScanners(connection) + + migrateScannedPages(connection) + + migrateSessions(connection) + } + + // Entity: CustomScanner + // + protocolIdentifier column: String = eSCL + private fun migrateCustomScanners(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE customscanners ADD COLUMN protocolIdentifier TEXT NOT NULL DEFAULT 'eSCL'") + } + + // Entity: ScannedPage + // C originalScanSettings: type change: eSCL ScanSettings -> CommonScanSettings + // + outputName column: String? = null + private fun migrateScannedPages(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE scannedpages ADD COLUMN outputName TEXT") + + val scannedPagesQuery = connection.prepare("SELECT scanId, originalScanSettings FROM scannedpages") + val scannedPagesUpdate = connection.prepare("UPDATE scannedpages SET originalScanSettings = ? WHERE scanId = ?") + + val json = ScanSettingsJson.json + + scannedPagesUpdate.use { + scannedPagesQuery.use { + while (scannedPagesQuery.step()) { + check(scannedPagesQuery.getColumnName(0) == "scanId") + check(scannedPagesQuery.getColumnName(1) == "originalScanSettings") + + val scanId = scannedPagesQuery.getText(0) + + val oldOriginalScanSettingsString = scannedPagesQuery.getText(1) + val deserializedOldOriginalScanSettings = json + .decodeFromString(oldOriginalScanSettingsString) + + val newOriginalScanSettings = deserializedOldOriginalScanSettings.toCommonAbstraction() + val newOriginalScanSettingsString = json.encodeToString(newOriginalScanSettings) + + scannedPagesUpdate.bindText(1, newOriginalScanSettingsString) + scannedPagesUpdate.bindText(2, scanId) + scannedPagesUpdate.step() + scannedPagesUpdate.clearBindings() + } + } + } + } + + // Entity: Session + // C currentScanSettings: type change: eSCL ScanSettings? -> CommonScanSettings? + // C currentSettingsUIData: type change: ScanSettingsEnterableDataV0? -> ScanSettingsEnterableDataV1? + private fun migrateSessions(connection: SQLiteConnection) { + val json = ScanSettingsJson.json + + val sessionsQuery = connection.prepare("SELECT sessionId, currentScanSettings, currentSettingsUIData FROM sessions") + val sessionsUpdate = connection.prepare( + "UPDATE sessions SET currentScanSettings = ?, currentSettingsUIData = ? WHERE sessionId = ?" + ) + + sessionsUpdate.use { + sessionsQuery.use { + while (sessionsQuery.step()) { + check(sessionsQuery.getColumnName(0) == "sessionId") + check(sessionsQuery.getColumnName(1) == "currentScanSettings") + check(sessionsQuery.getColumnName(2) == "currentSettingsUIData") + + val sessionId = sessionsQuery.getText(0) + + val oldCurrentScanSettingsString = if (!sessionsQuery.isNull(1)) { + sessionsQuery.getText(1) + } else { + null + } + + val oldCurrentSettingsUIDataString = if (!sessionsQuery.isNull(2)) { + sessionsQuery.getText(2) + } else { + null + } + + val oldCurrentScanSettings = oldCurrentScanSettingsString?.let { + json + .decodeFromString(it) + } + + val oldCurrentSettingsUIData = oldCurrentSettingsUIDataString?.let { + json + .decodeFromString(it) + } + + val newCurrentScanSettings = oldCurrentScanSettings?.toCommonAbstraction() + + val newCurrentSettingsUIData = oldCurrentSettingsUIData?.toV1() + + val newCurrentScanSettingsString = json.encodeToString(newCurrentScanSettings) + val newCurrentSettingsUIDataString = json.encodeToString(newCurrentSettingsUIData) + + sessionsUpdate.bindText(1, newCurrentScanSettingsString) + sessionsUpdate.bindText(2, newCurrentSettingsUIDataString) + sessionsUpdate.bindText(3, sessionId) + sessionsUpdate.step() + sessionsUpdate.clearBindings() + } + } + } + } +} 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..1ba2f4f6 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/CommonScanSettingsTypeConverter.kt @@ -0,0 +1,8 @@ +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..0453f171 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/JsonSerializationTypeConverter.kt @@ -0,0 +1,21 @@ +package io.github.chrisimx.scanbridge.db.typeconverters + +import androidx.room.TypeConverter +import io.github.chrisimx.scanbridge.ScanSettingsJson +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..dd41d7ec 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsTypeConverter.kt @@ -1,22 +1,5 @@ package io.github.chrisimx.scanbridge.db.typeconverters -import androidx.room.TypeConverter import io.github.chrisimx.esclkt.ScanSettings -import io.github.chrisimx.scanbridge.ScanSettingsJson -class ScanSettingsTypeConverter { - - @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..26d667ff 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/typeconverters/ScanSettingsUiDataTypeConverter.kt @@ -1,22 +1,14 @@ package io.github.chrisimx.scanbridge.db.typeconverters -import androidx.room.TypeConverter -import io.github.chrisimx.scanbridge.ScanSettingsJson -import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableData - -class ScanSettingsUiDataTypeConverter { - - @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) - } -} +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV0 +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 + +class ScanSettingsUiDataTypeConverterV0 : + JsonSerializationTypeConverter( + ScanSettingsEnterableDataV0.serializer() + ) + +class ScanSettingsUiDataTypeConverterV1 : + JsonSerializationTypeConverter( + ScanSettingsEnterableDataV1.serializer() + ) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt new file mode 100644 index 00000000..0359bf0c --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt @@ -0,0 +1,244 @@ +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) + + companion object { + private const val SECURE_SCANNER_DISCOVER_TYPE = "_uscans._tcp" + private const val INSECURE_SCANNER_DISCOVER_TYPE = "_uscan._tcp" + } + + private val isScannerReachableMap = mutableMapOf>() + private val isScannerReachableMapMutex = Mutex() + + private val capabilityFetchDispatcher = Dispatchers.IO.limitedParallelism(8) + + @OptIn(ExperimentalCoroutinesApi::class) + private val _scanners: StateFlow> = + combine( + mdnsDiscoveryInsecureEscl.foundServices, + mdnsDiscoverySecureEscl.foundServices + ) { 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 = + 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? = 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..c30dac82 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt @@ -0,0 +1,359 @@ +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.esclkt.JobState +import io.github.chrisimx.esclkt.ScanJob +import io.github.chrisimx.esclkt.anyscancompat.CommonAbstractionConversionResult +import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.esclkt.anyscancompat.toESCLScanSettings +import io.github.chrisimx.safektor.ESCLHttpCallResult +import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.model.ScanningError +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 + override val exampleScannerIdentifierString: String + get() = "http://192.168.178.122/eSCL/" + + 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 + ) + + private fun Url.addTrailingSlash(): Url = Url( + if (this.toString().endsWith("/")) { + this.toString() + } else { + "$this/" + } + ) + + override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { + val scannerUrlHandle = + scanner as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) + + val httpConfig = settings.toHttpClientConfig() + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = ESCLRequestClient( + baseUrl = scannerUrlHandle.url.addTrailingSlash(), + 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.addTrailingSlash(), + httpClient + ) + + suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { + logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } + 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..6e04d364 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/DiscoveredScanner.kt @@ -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..29d6a5ec 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/HttpClientConfig.kt @@ -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..c666dcf3 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/IpAddress.kt @@ -8,9 +8,9 @@ sealed interface IpAddress { val urlHost: String get() = text - class V4( - override val bytes: ByteArray, - ) : IpAddress { + fun isLinkLocal(): Boolean = false // TODO: Implement link local detection + + class V4(override val bytes: ByteArray) : IpAddress { init { require(bytes.size == 4) { "A IPv4 address needs to be 4 bytes long" @@ -21,10 +21,7 @@ sealed interface IpAddress { get() = bytes.joinToString(".") { it.toUByte().toString() } } - class V6( - override val bytes: ByteArray, - val scopeId: String? = null - ) : IpAddress { + class V6(override val bytes: ByteArray, val scopeId: String? = null) : IpAddress { init { require(bytes.size == 16) { "A IPv6 address needs to be 16 bytes long" diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt index d8440620..e0e764c4 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/NumberValidationResult.kt @@ -1,10 +1,11 @@ package io.github.chrisimx.scanbridge.model +import io.github.chrisimx.anyscan.LengthUnit import kotlinx.serialization.Serializable @Serializable sealed class NumberValidationResult { - data class Success(val value: Double) : NumberValidationResult() + data class Success(val value: LengthUnit) : NumberValidationResult() data class OutOfRange(val min: Double, val max: Double) : NumberValidationResult() data object NotANumber : NumberValidationResult() } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/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..42262b87 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanProtocolScannedPage.kt @@ -0,0 +1,21 @@ +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..f2a4ce8f 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt @@ -6,11 +6,26 @@ 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, val widthString: String = "", val heightString: String = "", val maximumSize: Boolean = true +) { + fun toV1(): ScanSettingsEnterableDataV1 = ScanSettingsEnterableDataV1( + customMenuEnabled = customMenuEnabled, + widthString = widthString, + heightString = heightString, + maximumSize = maximumSize + ) +} + +@Serializable +data class ScanSettingsEnterableDataV1( + 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..6d96629f --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt @@ -0,0 +1,21 @@ +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..5cfc4a21 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanningError.kt @@ -0,0 +1,37 @@ +package io.github.chrisimx.scanbridge.model + +sealed class ScanningError(val unlocalizedMessage: String) { + data class UnsupportedContentType(val contentType: String) : + ScanningError( + "Unsupported content type: $contentType" + ) + data class InvalidScanHandle(val handle: ScannerHandle) : + ScanningError( + "Invalid scan handle: $handle" + ) + data class JobCreationFailed(val creationError: String) : + ScanningError( + "Job creation failed: $creationError" + ) + + data class JobCompletedInFailedState(val jobStatus: String) : + ScanningError( + "Job completed but failed. Job status: $jobStatus" + ) + + data class CannotGetScannerCaps(val error: String) : + ScanningError( + "Cannot get scanner capabilities: $error" + ) + + data 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/InitialScanSettingsProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt new file mode 100644 index 00000000..98a47d93 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/InitialScanSettingsProvider.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.ports + +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities + +interface InitialScanSettingsProvider { + fun applyDefaults(editor: CommonScanSettingsEditor, capabilities: CommonScannerCapabilities) +} 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..8e2b963c --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScannerDiscoveryBackend.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.ports + +import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import kotlinx.coroutines.flow.Flow + +interface ScannerDiscoveryBackend : AutoCloseable { + val scanners: Flow> +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt new file mode 100644 index 00000000..de30f205 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt @@ -0,0 +1,88 @@ +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 + + /** + * An exemplary string that shows what the scanner identifier string usually looks like. + */ + val exampleScannerIdentifierString: String + + /** + * Whether this scanning protocol uses URLs for identifying a scanner. + * This flag is needed so that it can be decided for which protocols the manual URL entry + * 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/ports/multicast/MulticastLockHandler.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/multicast/MulticastLockHandler.kt new file mode 100644 index 00000000..52644b22 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/multicast/MulticastLockHandler.kt @@ -0,0 +1,6 @@ +package io.github.chrisimx.scanbridge.ports.multicast + +interface MulticastLockHandler { + fun acquire() + fun release() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt new file mode 100644 index 00000000..db5417c8 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt @@ -0,0 +1,27 @@ +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..2af43708 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt @@ -0,0 +1,55 @@ +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 io.github.chrisimx.scanbridge.ports.ScanningProtocolManager +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow + +data class ProtocolWithExampleHandleString(val protocolIdentifier: String, val exampleScannerIdentifierString: String) + +class ScannerDiscoveryScreenViewModel( + val customScannerRepo: CustomScannerRepository, + val discoveryUsecase: DiscoveryUsecase, + val protocolManager: ScanningProtocolManager +) : 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(), emptyList()) + + /** + * The protocols for which we can create a custom scanner. + */ + val protocolsForCustomScanners = protocolManager + .getAllProtocols() + .map { ProtocolWithExampleHandleString(it.protocolIdentifier, it.exampleScannerIdentifierString) } + + fun addScanner(scanner: CustomScanner) { + viewModelScope.launch { + customScannerRepo.add(scanner) + } + } + + 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/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt new file mode 100644 index 00000000..482aba0b --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt @@ -0,0 +1,15 @@ +package io.github.chrisimx.scanbridge.usecases + +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import kotlin.uuid.Uuid + +interface StartScanUseCase { + fun startScan( + ownerSessionId: Uuid, + scannerHandle: ScannerHandle, + scanSettings: CommonScanSettings, + connectionSettings: ScannerConnectionSettings + ) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt new file mode 100644 index 00000000..a1b79b23 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt @@ -0,0 +1,70 @@ +package io.github.chrisimx.scanbridge.wsd + +import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.model.UrlScannerHandle +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler +import io.github.chrisimx.wsdkt.wsdiscovery.WsScannerServiceDiscovery +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam + +@OptIn(ExperimentalAtomicApi::class) +class WsdScannerDiscoveryBackend( + val multicastLockHandler: MulticastLockHandler, + @InjectedParam + val protocol: WsdScanningProtocol, + @InjectedParam + val loggerFactory: ScanBridgeLoggerFactory, + @InjectedParam + val coroutineScope: CoroutineScope +) : ScannerDiscoveryBackend { + private val logger = loggerFactory.withClass(this::class) + + fun discoveryDebugLog(message: String) { + logger.debug { message } + } + + val wsScannerServiceDiscovery: WsScannerServiceDiscovery = WsScannerServiceDiscovery( + debugLog = ::discoveryDebugLog + ) + + init { + multicastLockHandler.acquire() + + coroutineScope.launch { + try { + awaitCancellation() + } finally { + close() + } + } + } + + override fun close() {} + + override val scanners: Flow> = + wsScannerServiceDiscovery.discoveredDevices + .onStart { + multicastLockHandler.acquire() + } + .onCompletion { cause -> + multicastLockHandler.release() + }.map { scannerList -> + scannerList.map { scannerService -> + DiscoveredScanner( + name = scannerService.name, + handle = UrlScannerHandle(protocol, scannerService.url), + scannerCaps = null, + iconUrl = null + ) + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt new file mode 100644 index 00000000..da476498 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt @@ -0,0 +1,202 @@ +package io.github.chrisimx.scanbridge.wsd + +import io.github.chrisimx.anyscan.CommonScanSettings +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.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.ports.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend +import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler +import io.github.chrisimx.wsdkt.anyscancompat.toCommonAbstraction +import io.github.chrisimx.wsdkt.anyscancompat.toWSDScanTicket +import io.github.chrisimx.wsdkt.client.WsdScanServiceClient +import io.github.chrisimx.wsdkt.scanjob.ScanJob +import io.ktor.http.Url +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow + +class WsdScanningProtocol( + private val loggerFactory: ScanBridgeLoggerFactory, + private val httpClientFactory: HttpClientFactory, + private val multicastLockHandler: MulticastLockHandler +) : ScanningProtocol { + private val logger = loggerFactory.withClass(this::class) + + override val protocolIdentifier: String + get() = "WSD" + override val usesUrls: Boolean + get() = true + override val exampleScannerIdentifierString: String + get() = "http://192.168.178.122/WebServices/ScannerService" + + override fun createScannerHandle(scannerIdentifier: String): ScannerHandle? { + val url = runCatching { Url(scannerIdentifier) }.getOrNull() + + return url?.let { + UrlScannerHandle( + this, + it + ) + } + } + + override fun createDiscoveryBackend(coroutineScope: CoroutineScope, settings: ScannerConnectionSettings): ScannerDiscoveryBackend = + WsdScannerDiscoveryBackend( + protocol = this, + loggerFactory = loggerFactory, + multicastLockHandler = multicastLockHandler, + coroutineScope = coroutineScope + ) + + override suspend fun capabilitiesFor(scanner: ScannerHandle, settings: ScannerConnectionSettings): ScannerCapabilitiesResult { + val scannerUrlHandle = + scanner as? UrlScannerHandle ?: return ScannerCapabilitiesResult.InvalidScannerHandle(scanner) + + val httpConfig = settings.toHttpClientConfig() + val httpClient = httpClientFactory.create(httpConfig) + + val esclRequestClient = WsdScanServiceClient(scannerUrlHandle.url, httpClient) + + val allScannerElementsResult = httpClient.use { + esclRequestClient.retrieveAllScannerElements() + } + + when (allScannerElementsResult) { + is WsdScanServiceClient.RetrieveAllScannerElementsResult.RequestFailure -> return ScannerCapabilitiesResult.Failure( + allScannerElementsResult.error + ) + + WsdScanServiceClient.RetrieveAllScannerElementsResult.ScannerConfigurationNotFound -> return ScannerCapabilitiesResult.Failure( + allScannerElementsResult + ) + + // TODO: Map UnknownCertificate error here to the right type + is WsdScanServiceClient.RetrieveAllScannerElementsResult.Success -> {} + } + + val commonScanCaps = allScannerElementsResult + .allScannerElements + .toCommonAbstraction() + + return ScannerCapabilitiesResult.Success(commonScanCaps) + } + + override fun executeScanJob( + handle: ScannerHandle, + settings: ScannerConnectionSettings, + jobScanSettings: CommonScanSettings, + cancelled: StateFlow + ): Flow = flow { + val scannerUrlHandle = + handle as? UrlScannerHandle + + if (scannerUrlHandle == null) { + emit( + ScanJobProcessingEvent.Failure( + ScanningError.InvalidScanHandle(handle) + ) + ) + return@flow + } + + val httpConfig = settings.toHttpClientConfig() + val httpClient = httpClientFactory.create(httpConfig) + + val wsdRequestClient = WsdScanServiceClient(scannerUrlHandle.url, httpClient) + + suspend fun abortIfCancelling(scanJob: ScanJob? = null): Boolean = if (cancelled.value) { + logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } + scanJob?.cancel() + + emit(ScanJobProcessingEvent.Cancelled) + true + } else { + false + } + + if (abortIfCancelling()) return@flow + + val caps = wsdRequestClient.retrieveAllScannerElements() + + if (caps !is WsdScanServiceClient.RetrieveAllScannerElementsResult.Success) { + emit( + ScanJobProcessingEvent.Failure( + ScanningError.CannotGetScannerCaps(caps.toString()) + ) + ) + return@flow + } + + val scanTicket = jobScanSettings.toWSDScanTicket( + caps.allScannerElements.scannerConfiguration + ) + + val jobCreationResult = wsdRequestClient.createScanJob(scanTicket) + + if (jobCreationResult !is WsdScanServiceClient.CreateScanJobResult.Success) { + emit( + ScanJobProcessingEvent.Failure( + ScanningError.JobCreationFailed(jobCreationResult.toString()) + ) + ) + return@flow + } + + val job = jobCreationResult.scanJob + + if (abortIfCancelling(job)) return@flow + + while (true) { + if (abortIfCancelling(job)) return@flow + + val newPageResult = job.retrieveNextPage() + when (newPageResult) { + WsdScanServiceClient.RetrieveImageResult.NoFurtherPages -> { + break + } + + is WsdScanServiceClient.RetrieveImageResult.Success -> {} + + else -> { + job.cancel() + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError(newPageResult.toString(), "null") + ) + ) + return@flow + } + } + + val newPage = newPageResult.page + + if (newPage.contentType == null) { + job.cancel() + emit( + ScanJobProcessingEvent.Failure( + ScanningError.NextPageRetrievalError("Content type missing", "null") + ) + ) + return@flow + } + + emit( + ScanJobProcessingEvent.NewPage( + ScanProtocolScannedPage( + newPage.contentType!!, + newPage.data + ) + ) + ) + } + } +} 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..5872cf2c 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.1.3" 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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2986a6df..9c4e8bb5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,14 +21,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { - name = "Fireamp Snapshots" - url = uri("https://repo.fireamp.eu/repository/maven-snapshots/") - - content { - includeGroup("io.github.chrisimx") - } - } } }