diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index be1b92a6..753105fb 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -18,11 +18,6 @@ plugins { alias(libs.plugins.paraphrase) } -val gitHashProvider = providers.exec { - commandLine("git", "rev-parse", "--short", "HEAD") - isIgnoreExitValue = true -}.standardOutput.asText.map { it.trim() } - tasks.withType().all { compilerOptions { freeCompilerArgs = listOf("-Xnon-local-break-continue") @@ -31,7 +26,7 @@ tasks.withType().all { android { namespace = "io.github.chrisimx.scanbridge" - compileSdk = 36 + compileSdk = 37 androidResources { generateLocaleConfig = true @@ -40,9 +35,9 @@ android { defaultConfig { applicationId = "io.github.chrisimx.scanbridge" minSdk = 28 - targetSdk = 36 - versionCode = 2_001_005 // we just count up for each build - versionName = "2.2.0-alpha1" + targetSdk = 37 + versionCode = providers.gradleProperty("scanbridgeVersionCode").get().toInt() + versionName = providers.gradleProperty("scanbridgeVersion").get() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["escl_server_url"] = @@ -56,11 +51,8 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - buildConfigField("String", "GIT_COMMIT_HASH", "\"${gitHashProvider.get()}\"") - } - debug { - buildConfigField("String", "GIT_COMMIT_HASH", "\"${gitHashProvider.get()}\"") } + debug {} } flavorDimensions += "edition" @@ -104,16 +96,13 @@ dependencies { implementation(libs.koin.annotations) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.androix.navigation) - implementation(libs.zoomable) implementation(libs.kotlin.reflect) - implementation(libs.coil.compose) implementation(libs.timber) implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.serialization.json) 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) @@ -132,7 +121,6 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.material.icons.core) - implementation(libs.androidx.constraintlayout.compose) implementation(libs.itext7.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.logging) diff --git a/androidApp/gradle/libs.versions.toml b/androidApp/gradle/libs.versions.toml deleted file mode 100644 index e546184f..00000000 --- a/androidApp/gradle/libs.versions.toml +++ /dev/null @@ -1,100 +0,0 @@ -[versions] -agp = "9.1.0" -coilCompose = "3.3.0" -constraintlayoutCompose = "1.1.1" -datastore = "1.2.0" -esclkt = "2.0.6" -escl-mock-server = "1.0.1" -itextCore = "9.3.0" -kotlin = "2.3.20-Beta1" -coreKtx = "1.17.0" -junit = "4.13.2" -junitVersion = "1.3.0" -espressoCore = "3.7.0" -kotlinReflect = "2.1.20" -kotlinxSerializationJson = "1.9.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2025.10.00" -room = "2.8.4" -timber = "5.0.1" -zoomable = "0.18.0" -material3 = "1.5.0-alpha06" -materialIcons = "1.7.8" -navigationCompose = "2.9.5" -versionsPlugin = "0.53.0" -screengrab = "2.1.1" -ktor = "3.4.0" -koin = "4.2.0-RC1" -koin-plugin = "0.3.0" -protobuf-plugin = "0.9.6" -protobuf-kotlin-lite = "4.33.5" -rules = "1.7.0" - -[libraries] - -androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } - -androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } -androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } -androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } - -koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } -koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } -koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } -koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin" } - -# Android -koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -koin-android-test = { module = "io.insert-koin:koin-android-test", version.ref = "koin" } - -koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin"} - -koin-androix-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koin"} - -# Compose -koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } -koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation", version.ref = "koin" } -koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } - -ktor-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor"} -ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor"} -androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" } -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -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" } -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" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } -kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } -screengrab = { module = "tools.fastlane:screengrab", version.ref = "screengrab" } -timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } -zoomable = { module = "me.saket.telephoto:zoomable", version.ref = "zoomable" } -protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-kotlin-lite"} -androidx-rules = { group = "androidx.test", name = "rules", version.ref = "rules" } - -escl-mock-server = { module = "io.github.chrisimx:escl-mock-server", version.ref = "escl-mock-server"} - -[plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } -koin = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } -protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } -room = { id = "androidx.room", version.ref = "room"} \ No newline at end of file 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 ab3fe5b3..b2b480ea 100644 --- a/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt +++ b/androidApp/src/fdroid/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt @@ -6,32 +6,43 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.ExperimentalMaterial3Api +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.discovery +import scanbridge.composeui.generated.resources.header_scannerbrowser +import scanbridge.composeui.generated.resources.settings -@OptIn(ExperimentalMaterial3Api::class) -val STARTUP_TABS = listOf( - StartupScreen( - R.string.discovery, - R.string.header_scannerbrowser, - Icons.Filled.Home, - Icons.Outlined.Home, - true, - { innerPadding, navController, showCustomDialog, setShowCustomDialog -> - ScannerBrowser( - innerPadding, - navController, - showCustomDialog, - setShowCustomDialog +class FdroidStartupTabsProvider : StartupTabsProvider { + @OptIn(ExperimentalMaterial3Api::class) + override fun getStartupTabs(): List = + listOf( + StartupTabDefinition( + Res.string.discovery, + Res.string.header_scannerbrowser, + Icons.Filled.Home, + Icons.Outlined.Home, + true, + { innerPadding, navController, showCustomDialog, setShowCustomDialog -> + ScannerBrowser( + innerPadding, + navController, + showCustomDialog, + setShowCustomDialog + ) + } + ), + StartupTabDefinition( + Res.string.settings, + Res.string.settings, + Icons.Filled.Settings, + Icons.Outlined.Settings, + false, + { innerPadding, _, _, _ -> + AppSettingsScreen(innerPadding) + } ) - } - ), - StartupScreen( - R.string.settings, - R.string.settings, - Icons.Filled.Settings, - Icons.Outlined.Settings, - false, - { innerPadding, _, _, _ -> - AppSettingsScreen(innerPadding) - } - ) -) + ) + + +} + +val STARTUP_TABS_PROVIDER = FdroidStartupTabsProvider() diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidBuildInfoProvider.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidBuildInfoProvider.kt index d0b6b612..751581b0 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidBuildInfoProvider.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidBuildInfoProvider.kt @@ -5,9 +5,6 @@ import io.github.chrisimx.scanbridge.buildinfo.ScanBridgeEdition import io.github.chrisimx.scanbridge.model.Platform class AndroidBuildInfoProvider : BuildInfoProvider { - override val versionName: String = BuildConfig.VERSION_NAME - override val versionCode: Int = BuildConfig.VERSION_CODE - override val commit: String = BuildConfig.GIT_COMMIT_HASH override val edition: ScanBridgeEdition = when (BuildConfig.FLAVOR) { "play" -> ScanBridgeEdition.PLAYSTORE "fdroid" -> ScanBridgeEdition.FDROID diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt similarity index 93% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt rename to androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt index 76f02afc..333af815 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt @@ -21,13 +21,14 @@ package io.github.chrisimx.scanbridge import android.content.Context import android.content.Intent +import io.github.chrisimx.scanbridge.savelastroute.LastRouteRepository import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlinx.coroutines.runBlocking import timber.log.Timber -class CrashHandler(val context: Context, val lastRouteRepository: LastRouteRepository) : Thread.UncaughtExceptionHandler { +class AndroidCrashHandler(val context: Context, val lastRouteRepository: LastRouteRepository) : Thread.UncaughtExceptionHandler { private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt new file mode 100644 index 00000000..0e7b236b --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge + +import io.github.chrisimx.scanbridge.export.ExportCapabilitiesProvider +import io.github.chrisimx.scanbridge.scanning.FileSaveType + +class AndroidExportCapabilitiesProvider : ExportCapabilitiesProvider { + override val supportedFileSaveTypes: Set = setOf(FileSaveType.Save, FileSaveType.Share) +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt index 01a16386..b9496219 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt @@ -37,62 +37,9 @@ class CrashActivity : ComponentActivity() { Timber.plant(Timber.DebugTree()) setContent { - ScanBridgeTheme { - Scaffold( - floatingActionButton = { - FloatingActionButton({ - try { - val clipboard = getSystemService(ClipboardManager::class.java) - val clip = ClipData.newPlainText("Crash log", error) - clipboard.setPrimaryClip(clip) - } catch (e: Exception) { - Timber.e("Error in CrashActivity while trying to copy crash to clipboard: $e") - } - }) { - Row(horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.rounded_content_copy_24), - contentDescription = stringResource(R.string.copy), - Modifier.padding(start = 10.dp, top = 10.dp, bottom = 10.dp, end = 5.dp) - ) - Text( - stringResource(R.string.copy), - Modifier.padding(start = 5.dp, top = 10.dp, bottom = 10.dp, end = 10.dp) - ) - } - } - } - ) - { innerPadding -> - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .padding(innerPadding) - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painterResource(R.drawable.outline_error_24), - stringResource(R.string.crash), - Modifier.padding(20.dp).size(64.dp) - ) - - Column(modifier = Modifier.verticalScroll(scrollState)) { - Text( - stringResource(R.string.crash_occurred), - modifier = Modifier.padding(bottom = 14.dp), - style = MaterialTheme.typography.bodySmallEmphasized - ) - Text( - error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - ) - } - } - } - } + CrashDisplay( + error + ) } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt deleted file mode 100644 index 601e70f5..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ /dev/null @@ -1,160 +0,0 @@ -package io.github.chrisimx.scanbridge - -import android.graphics.BitmapFactory -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.snap -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FabPosition -import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -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.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.db.entities.ScannedPage -import io.github.chrisimx.scanbridge.uicomponents.CroppableAsyncImage -import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog -import io.github.chrisimx.scanbridge.util.clearAndNavigateTo -import io.github.chrisimx.scanbridge.util.cropWithRect -import io.github.chrisimx.scanbridge.util.getEditedImageName -import io.github.chrisimx.scanbridge.util.saveAsJPEG -import java.io.File -import java.nio.file.Files -import kotlin.io.path.Path -import kotlin.uuid.Uuid -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.saket.telephoto.zoomable.EnabledZoomGestures -import me.saket.telephoto.zoomable.ZoomSpec -import me.saket.telephoto.zoomable.rememberZoomableState -import me.saket.telephoto.zoomable.zoomable -import org.koin.compose.koinInject -import timber.log.Timber - -suspend fun finishCrop(cropRect: Rect, file: String): File? = withContext(Dispatchers.IO) { - val sourceBitmap = BitmapFactory.decodeFile(file) - if (sourceBitmap == null) { - Timber.e("Could not decode source bitmap for cropping") - return@withContext null - } - - val croppedBitmap = sourceBitmap.cropWithRect(cropRect) - sourceBitmap.recycle() - - val file = File(file) - val croppedFile = File(file.parent, file.getEditedImageName()) - croppedBitmap.saveAsJPEG(croppedFile) - croppedBitmap.recycle() - - return@withContext croppedFile -} - -// TODO: @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalTelephotoApi::class) -@Composable -fun CropScreen(scanId: Uuid, returnRoute: BaseRoute, navController: NavController) { - val context = LocalContext.current - val db: ScanBridgeDb = koinInject() - val scannedPageDao = db.scannedPageDao() - - val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 5f)) - val coroutineScope = rememberCoroutineScope() - var currentRect by remember { mutableStateOf(Rect(0f, 0f, 1f, 1f)) } - - var processing: Boolean by remember { mutableStateOf(false) } - - val currentPageMetadata: ScannedPage? by scannedPageDao.getByScanIdFlow(scanId).collectAsState(null) - - val save: () -> Unit = { - coroutineScope.launch(Dispatchers.Main) { - val pageMetadata = currentPageMetadata - if (processing || pageMetadata == null) return@launch - - processing = true - try { - val croppedFile = finishCrop(currentRect, pageMetadata.filePath) ?: return@launch - withContext(Dispatchers.IO) { - Files.deleteIfExists(Path(pageMetadata.filePath)) - } - db.scannedPageDao().update( - pageMetadata.copy( - filePath = croppedFile.absolutePath - ) - ) - navController.clearAndNavigateTo(returnRoute) - } finally { - processing = false - } - } - } - - BackHandler { - navController.clearAndNavigateTo(returnRoute) - } - - Scaffold( - modifier = Modifier.fillMaxSize(), - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = save, - modifier = Modifier.testTag("crop_finish"), - icon = { - Icon( - painter = painterResource(R.drawable.outline_crop_24), - contentDescription = stringResource(R.string.crop) - ) - }, - text = { Text(stringResource(R.string.crop_button)) } - ) - }, - floatingActionButtonPosition = FabPosition.Center - - ) { innerPadding -> - if (processing) { - LoadingDialog(text = R.string.cropping) - } - - Box( - modifier = Modifier - .fillMaxSize() - .zoomable( - zoomableState, - gestures = EnabledZoomGestures.ZoomOnly - ), - contentAlignment = Alignment.CenterHorizontally.plus(Alignment.CenterVertically) - ) { - CroppableAsyncImage( - modifier = Modifier - .padding(innerPadding) - .padding(start = 40.dp, end = 40.dp, bottom = 100.dp, top = 15.dp), - imageModel = currentPageMetadata?.filePath, - contentDescription = stringResource(R.string.desc_scanned_page), - additionalTouchAreaAround = 100.dp, - handleTouchRadius = 60.dp, - cropRectChanged = { currentRect = it }, - onPan = { - coroutineScope.launch { - zoomableState.panBy(it, snap()) - } - } - ) - } - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt index 53ebbaed..b5766c2e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt @@ -26,12 +26,13 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat -import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider +import io.github.chrisimx.localization.AndroidLocaleProvider +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.init import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import org.koin.android.scope.AndroidScopeComponent import org.koin.androidx.scope.activityScope @@ -58,6 +59,8 @@ class MainActivity : enableEdgeToEdge() super.onCreate(savedInstanceState) + FileKit.init(this) + localeProvider.update() Timber.i( diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt deleted file mode 100644 index fc4f7c45..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt +++ /dev/null @@ -1,143 +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 androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository -import io.github.chrisimx.scanbridge.startupmessages.StartupMessage -import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme -import io.github.chrisimx.scanbridge.uicomponents.CrashFileHandler -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import org.koin.compose.koinInject -import timber.log.Timber - -@Composable -fun ScanBridgeApp() { - ScanBridgeTheme { - val navController = rememberNavController() - var startDestination: Any? by remember { mutableStateOf(null) } - val coroutineScope = rememberCoroutineScope() - - val currentBackStackEntry by navController.currentBackStackEntryAsState() - val typedRoute = currentBackStackEntry?.toTypedRoute() - - val lastRouteRepository = koinInject() - val shownMessagesRepository = koinInject() - - val thanksForPurchaseAlreadyShown by shownMessagesRepository - .getWasShownFlow(StartupMessage.THANKS_FOR_PURCHASE) - .collectAsState(true) - - LaunchedEffect(Unit) { - Timber.d("Loading last route from shared preferences") - val lastRoute = lastRouteRepository.getLastRoute() - if (lastRoute != null) { - try { - Timber.d("Last route found: $lastRoute") - val savedRoute = Json.decodeFromString(lastRoute) - startDestination = savedRoute - return@LaunchedEffect - } catch (e: Exception) { - Timber.e(e, "Failed to deserialize saved route") - } - } - startDestination = StartUpScreenRoute - } - - if (startDestination != null) { - ScanBridgeNavHost(navController, startDestination!!) - } - - LaunchedEffect(typedRoute) { - typedRoute?.let { route -> - val json = Json.encodeToString(route) - Timber.d("Route saved as: $json") - - lastRouteRepository.setLastRoute(json) - } - } - - val markThanksMessageAsRead = { - coroutineScope.launch { - shownMessagesRepository.setShown(StartupMessage.THANKS_FOR_PURCHASE, true) - } - } - - if (!thanksForPurchaseAlreadyShown) { - AlertDialog( - { - markThanksMessageAsRead() - }, - { - TextButton( - onClick = { - markThanksMessageAsRead() - } - ) { - Text(stringResource(R.string.okay_text)) - } - }, - icon = { - Icon( - Icons.Rounded.Favorite, - "Thank you", - Modifier.size(60.dp), - tint = Color.Red - ) - }, - title = { - Text( - stringResource(R.string.thank_message_play_one_title), - textAlign = TextAlign.Center - ) - }, - text = { - Text( - stringResource(R.string.thank_message_play_one) - ) - } - ) - } - - CrashFileHandler() - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 680d8d22..0ab60811 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -1,58 +1,35 @@ package io.github.chrisimx.scanbridge -import AndroidHttpClientFactory -import AndroidMdnsDiscoverService -import AndroidMulticastLockHandler -import AndroidScanBridgeDbBuilderFactory +import KOIN_MODULE_JVM_AND_ANDROID_PLATFORM import android.app.Application import android.content.Context import coil3.ImageLoader import coil3.network.ktor3.KtorNetworkFetcherFactory -import io.github.chrisimx.localization.JvmNumberFormatter -import io.github.chrisimx.scanbridge.adapters.KoinBasedScanningProtocolManager -import io.github.chrisimx.scanbridge.adapters.RoomBackedCustomScannerRepository -import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository -import io.github.chrisimx.scanbridge.appsettings.AppSettingsViewModel -import io.github.chrisimx.scanbridge.appsettings.RoomAppSettingsRepository import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider -import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel -import io.github.chrisimx.scanbridge.db.DefaultScanBridgeDbFactory -import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.cropfeature.AndroidImageCropService +import io.github.chrisimx.scanbridge.cropfeature.CropScreenViewModel +import io.github.chrisimx.scanbridge.cropfeature.FinishCropUseCase +import io.github.chrisimx.scanbridge.cropfeature.ImageCropService import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory -import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory -import io.github.chrisimx.scanbridge.db.migrations.ROOM_MIGRATIONS -import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory -import io.github.chrisimx.scanbridge.initialscansettings.DefaultInitialScanSettingsProvider -import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider -import io.github.chrisimx.scanbridge.localization.NumberFormatter +import io.github.chrisimx.scanbridge.export.ExportCapabilitiesProvider +import io.github.chrisimx.scanbridge.imagerotation.AndroidImageRotationService +import io.github.chrisimx.scanbridge.imagerotation.ImageRotationService +import io.github.chrisimx.scanbridge.koin.KOIN_MODULE_COMMON +import io.github.chrisimx.scanbridge.koin.KOIN_MODULE_COMPOSE_UI_COMMON import io.github.chrisimx.scanbridge.migrations.MigrationExecutor import io.github.chrisimx.scanbridge.migrations.RoomBackedMigrationExecutor import io.github.chrisimx.scanbridge.migrations.ds2room.DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES import io.github.chrisimx.scanbridge.migrations.migrationsModule import io.github.chrisimx.scanbridge.model.HttpClientConfig -import io.github.chrisimx.scanbridge.ports.CustomScannerRepository import io.github.chrisimx.scanbridge.ports.HttpClientFactory -import io.github.chrisimx.scanbridge.ports.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory -import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler import io.github.chrisimx.scanbridge.proto.ShownMessages import io.github.chrisimx.scanbridge.repositories.DatastoreLastRouteRepository import io.github.chrisimx.scanbridge.repositories.DatastoreShownMessagesRepository -import io.github.chrisimx.scanbridge.repositories.RoomLastRouteRepository -import io.github.chrisimx.scanbridge.savelastusedscansettings.LastUsedScanSettingsRepository -import io.github.chrisimx.scanbridge.savelastusedscansettings.RoomLastUsedScanSettingsRepository -import io.github.chrisimx.scanbridge.scan.AndroidStartScanUseCase -import io.github.chrisimx.scanbridge.scannerdiscovery.DiscoveryUsecase -import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel -import io.github.chrisimx.scanbridge.services.AndroidLocaleProvider -import io.github.chrisimx.scanbridge.services.DebugLogService -import io.github.chrisimx.scanbridge.services.FileDebugLogService -import io.github.chrisimx.scanbridge.services.ScanJobRepository -import io.github.chrisimx.scanbridge.startupmessages.RoomShownStartupMessagesRepository +import io.github.chrisimx.scanbridge.scan.AndroidScanExecutionEntryPoint +import io.github.chrisimx.scanbridge.scanning.ScanExecutionEntryPoint import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository -import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -69,75 +46,38 @@ import org.koin.plugin.module.dsl.single import org.koin.plugin.module.dsl.viewModel import timber.log.Timber -fun createScanBridgeDb(factory: ScanBridgeDbFactory): ScanBridgeDb = factory.createInstance() - -fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): ImageLoader { - val ktorClient: HttpClient = factory.create( - HttpClientConfig( - disableCertValidation = true, - debugLogging = false, - requestTimeoutInSeconds = 2u, - connectTimeoutInSeconds = 2u, - socketTimeoutInSeconds = 2u - ) - ) - return ImageLoader.Builder(context) - .components { - add( - KtorNetworkFetcherFactory( - httpClient = ktorClient - ) - ) - } - .build() -} -val appModule = module { - single() bind Thread.UncaughtExceptionHandler::class - single() bind LocaleProvider::class - single() bind AppSettingsRepository::class - single() bind DebugLogService::class - single() bind HttpClientFactory::class - single() bind ScanBridgeLoggerFactory::class - single() +val androidPlatformModule = module { + single() bind Thread.UncaughtExceptionHandler::class single() bind MigrationExecutor::class includes(migrationsModule) single() bind ScanBridgeDbBuilderFactory::class - single() bind ScanBridgeDbFactory::class - single { - create(::createScanBridgeDb) - } - single(named("scannerIconImageLoader")) { - create(::createScannerIconImageLoader) - } + factory() bind MdnsDiscoverService::class single() - single() bind LastRouteRepository::class - single() bind LastUsedScanSettingsRepository::class single(named("legacyDatastoreShownMessages")) { DatastoreShownMessagesRepository( get(named()), get() ) } bind ShownStartupMessagesRepository::class - single() bind ShownStartupMessagesRepository::class - single() bind ScanningProtocolManager::class - single() bind InitialScanSettingsProvider::class - single() bind PaperFormatProvider::class - factory() - viewModel() - single() bind CustomScannerRepository::class - single() - viewModel() + single() bind MulticastLockHandler::class - single() bind StartScanUseCase::class + single() bind ScanExecutionEntryPoint::class + + single() bind ImageRotationService::class single() bind BuildInfoProvider::class - viewModel() - single() bind NumberFormatter::class + single { + STARTUP_TABS_PROVIDER + } + + single() bind ExportCapabilitiesProvider::class + + single() bind ImageCropService::class - includes(SCAN_PROTOCOLS, ROOM_MIGRATIONS, DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) + includes(DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } class ScanBridgeApplication : Application() { @@ -145,7 +85,7 @@ class ScanBridgeApplication : Application() { super.onCreate() startKoin { androidContext(this@ScanBridgeApplication) - modules(appModule) + modules(KOIN_MODULE_COMMON, androidPlatformModule, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM, KOIN_MODULE_COMPOSE_UI_COMMON) } Timber.plant(Timber.DebugTree()) 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 74a4122f..bd913730 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 @@ -9,25 +9,10 @@ import android.content.Context import android.content.Intent import android.os.IBinder import androidx.core.app.NotificationCompat -import androidx.room.immediateTransaction -import androidx.room.useWriterConnection -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 -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 java.io.File -import kotlin.jvm.java -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid +import io.github.chrisimx.scanbridge.scanning.ScanExecutor +import io.github.chrisimx.scanbridge.scanning.ScanJobRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -54,17 +39,14 @@ class ScanJobForegroundService : Service() { } } - private val scanJobs: ScanJobRepository by inject() - - private val httpClientFactory: HttpClientFactory by inject() - - private val db: ScanBridgeDb by inject() - private val scannedPageDao: ScannedPageDao = db.scannedPageDao() - @Volatile private var isRunning = false private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scanJobs: ScanJobRepository by inject() + + private val scanExecutor: ScanExecutor by inject() + override fun onCreate() { super.onCreate() Timber.d("Foreground service created") @@ -90,14 +72,8 @@ class ScanJobForegroundService : Service() { serviceScope.launch { try { - scanJobs.setJobRunning(true) - var job = scanJobs.nextJob() - while (job != null) { - doScan(job) - job = scanJobs.nextJob() - } + scanExecutor.executeScans() } finally { - scanJobs.setJobRunning(false) stopSelf() } } @@ -155,106 +131,4 @@ class ScanJobForegroundService : Service() { val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(serviceChannel) } - - @OptIn(ExperimentalUuidApi::class) - private suspend fun doScan(scanJob: ScanJob) { - scanJobs.notifyStarted(scanJob) - - val scanningProtocol = scanJob.scannerHandle.protocol - val scanningFlow = scanningProtocol.executeScanJob( - scanJob.scannerHandle, - scanJob.connectionSettings, - scanJob.scanSettings, - scanJobs.shouldCancel - ) - - var failed = false - - var pageCounter = 1 - - scanningFlow.collect { processingEvent -> - when (processingEvent) { - ScanJobProcessingEvent.Cancelled -> scanJobs.setCancel(false) - - is ScanJobProcessingEvent.Failure -> { - failed = true - scanJobs.notifyFailed(scanJob, processingEvent.error) - return@collect - } - - is ScanJobProcessingEvent.NewPage -> { - val pageData = processingEvent.scannedPage - - val scanPageFileName = "scan-" + Uuid.random().toString() - val scanPageFile = File(application.filesDir, scanPageFileName) - - scanPageFile.writeBytes(pageData.data) - - when (pageData.contentType) { - // TODO: Extract this conversion to a service - "image/jpeg" -> { - addScan( - scanJob.ownerSessionId, - scanPageFile.absolutePath, - scanJob.scanSettings, - ScanRelativeRotation.Original, - "scan-${pageCounter.toString().padStart(4, '0')}.jpg" - ) - } - - "application/pdf" -> { - val extractedImages = extractPdfImages( - scanPageFile.absolutePath, - File(scanPageFile.parent!!) - ) - - extractedImages.forEach { - addScan(scanJob.ownerSessionId, it, scanJob.scanSettings, ScanRelativeRotation.Original) - } - } - - else -> { - failed = true - scanJobs.notifyFailed(scanJob, ScanningError.UnsupportedContentType(pageData.contentType)) - return@collect - } - } - - pageCounter++ - } - } - } - - if (failed) return - - scanJobs.notifyCompleted(scanJob) - } - - suspend fun addScan( - sessionID: Uuid, - path: String, - settings: CommonScanSettings, - rotation: ScanRelativeRotation, - fileName: String? = null - ) { - Timber.d("Adding scan: $path, $rotation") - db.useWriterConnection { - it.immediateTransaction { - val highestIdx = scannedPageDao.getHighestIdxForSession(sessionID) ?: -1 - - Timber.d("Inserting scan with index ${highestIdx + 1}") - scannedPageDao.insertAll( - ScannedPage( - Uuid.generateV4(), - sessionID, - path, - settings, - rotation, - highestIdx + 1, - fileName - ) - ) - } - } - } } 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 deleted file mode 100644 index f71f678b..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ /dev/null @@ -1,96 +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 androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import io.github.chrisimx.anyscan.CommonScannerCapabilities -import io.github.chrisimx.scanbridge.ScanSettingsComposableStateHolder -import java.io.File -import kotlin.uuid.Uuid - -data class ErrorDescription(val pretext: Int?, val icon: Int?, val text: String?) - -data class ScanningScreenData( - val sessionID: Uuid, - val confirmDialogShown: MutableState = mutableStateOf(false), - val confirmPageDeleteDialogShown: MutableState = mutableStateOf(false), - val error: MutableState = mutableStateOf(null), - val scanSettingsVM: MutableState = mutableStateOf(null), - val capabilities: MutableState = mutableStateOf(null), - val scanSettingsMenuOpen: MutableState = mutableStateOf(false), - val showExportOptions: MutableState = mutableStateOf(false), - val showSaveOptions: MutableState = mutableStateOf(false), - val exportOptionsPopupPosition: MutableState?> = mutableStateOf(null), - val savePopupPosition: MutableState?> = mutableStateOf(null), - val stateProgressStringRes: MutableState = mutableStateOf(null), - val sourceFileToSave: MutableState = mutableStateOf(null), - val isRotating: MutableState = mutableStateOf(false) -) { - fun toImmutable() = ImmutableScanningScreenData( - sessionID, - confirmDialogShown, - confirmPageDeleteDialogShown, - error, - scanSettingsVM, - capabilities, - scanSettingsMenuOpen, - showExportOptions, - showSaveOptions, - exportOptionsPopupPosition, - savePopupPosition, - stateProgressStringRes, - sourceFileToSave, - isRotating - ) -} - -data class ImmutableScanningScreenData( - val sessionID: Uuid, - private val confirmDialogShownState: State, - private val confirmPageDeleteDialogShownState: State, - private val errorState: State, - private val scanSettingsVMState: State, - private val capabilitiesState: State, - private val scanSettingsMenuOpenState: State, - private val showExportOptionsState: State, - private val showSaveOptionsState: State, - private val exportOptionsPopupPositionState: State?>, - private val saveOptionsPopupPositionState: State?>, - private val progressStringResState: State, - private val sourceFileToSaveState: State, - private val isRotatingState: State -) { - val confirmDialogShown by confirmDialogShownState - val confirmPageDeleteDialogShown by confirmPageDeleteDialogShownState - val scanSettingsVM by scanSettingsVMState - val scanSettingsMenuOpen by scanSettingsMenuOpenState - val progressStringResource by progressStringResState - val capabilities by capabilitiesState - val error by errorState - val showExportOptions by showExportOptionsState - val showSaveOptions by showSaveOptionsState - val exportOptionsPopupPosition by exportOptionsPopupPositionState - val saveOptionsPopupPosition by saveOptionsPopupPositionState - val sourceFileToSave by sourceFileToSaveState - val isRotating by isRotatingState -} 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 deleted file mode 100644 index f1569f03..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ /dev/null @@ -1,716 +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 android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import androidx.activity.result.ActivityResultLauncher -import androidx.compose.material3.SnackbarHostState -import androidx.core.content.FileProvider -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.application -import androidx.lifecycle.viewModelScope -import androidx.room.immediateTransaction -import androidx.room.useWriterConnection -import com.itextpdf.io.image.ImageDataFactory -import com.itextpdf.kernel.geom.PageSize -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.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.ScanSettingsComposableStateHolder -import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository -import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.db.entities.AppSettings -import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings -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.initialscansettings.InitialScanSettingsProvider -import io.github.chrisimx.scanbridge.model.ScanRelativeRotation -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.ScannerCapabilitiesResult -import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings -import io.github.chrisimx.scanbridge.savelastusedscansettings.LastUsedScanSettingsRepository -import io.github.chrisimx.scanbridge.services.ScanJobRepository -import io.github.chrisimx.scanbridge.usecases.StartScanUseCase -import io.github.chrisimx.scanbridge.util.getEditedImageName -import io.github.chrisimx.scanbridge.util.rotateBy90 -import io.github.chrisimx.scanbridge.util.saveAsJPEG -import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage -import io.github.chrisimx.scanbridge.util.zipFiles -import java.io.File -import java.nio.file.Files -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -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 -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koin.core.annotation.InjectedParam -import org.koin.core.scope.Scope -import timber.log.Timber - -enum class ScanningScreenEvent { - SCAN_FINISHED, - SCAN_STARTED -} - -class ScanningScreenViewModel( - @InjectedParam - val scannerHandle: ScannerHandle, - @InjectedParam - val timeout: UInt, - @InjectedParam - val withDebugInterceptor: Boolean, - @InjectedParam - val certificateValidationDisabled: Boolean, - @InjectedParam - val sessionID: Uuid, - val db: ScanBridgeDb, - application: Application, - val scanJobRepo: ScanJobRepository, - val appSettingsRepository: AppSettingsRepository, - val lastUsedScanSettingsRepo: LastUsedScanSettingsRepository, - val initialScanSettingsProvider: InitialScanSettingsProvider, - val startScanUseCase: StartScanUseCase, - val koinScope: Scope -) : AndroidViewModel(application) { - private val _scanningScreenData = - ScanningScreenData( - sessionID - ) - val scanningScreenData: ImmutableScanningScreenData - get() = _scanningScreenData.toImmutable() - - private val tmpFileDao = db.tmpFileDao() - private val scannedPageDao = db.scannedPageDao() - private val sessionDao = db.sessionDao() - - val tempFiles: StateFlow> = tmpFileDao.getFilesFlowBySessionId(sessionID) - .onEach { Timber.d("Temp files changed: $it") } - .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) - - val scannedPages: StateFlow> = scannedPageDao.getAllForSessionFlow(sessionID) - .onEach { Timber.d("Scanned pages changed: $it") } - .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) - - val session: StateFlow = sessionDao.getSessionFlowById(sessionID) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) - - val isScanJobRunning = scanJobRepo.isJobRunning - val isScanJobCancelling = scanJobRepo.shouldCancel - - val currentPageIdx = session.map { - it?.currentPage ?: 0 - }.stateIn(viewModelScope, SharingStarted.Eagerly, 0) - - val currentPage = combine(currentPageIdx, scannedPages) { pageIdx, pages -> - pages.getOrNull(pageIdx) - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) - - suspend fun addTempFile(file: File) { - val tmpFile = TempFile(Uuid.generateV4(), sessionID, file.absolutePath) - tmpFileDao.insertAll(tmpFile) - } - - suspend fun getPageIdx(): Int = sessionDao.getSessionById(sessionID)?.currentPage ?: 0 - - fun setCancelling(value: Boolean) { - scanJobRepo.setCancel(value) - } - - init { - session - .filter { it?.currentScanSettings != null && it.currentSettingsUIData != null } - .map { - LastUsedScanSettings( - lastUsedScanSettings = it!!.currentScanSettings!!, - lastUsedScanSettingsEnterable = it.currentSettingsUIData!! - ) - } - .distinctUntilChanged() - .onEach { newLastUsedScanSettings -> - lastUsedScanSettingsRepo.setLastUsedScanSettings( - newLastUsedScanSettings - ) - } - .launchIn(viewModelScope) - } - - fun setPageIdx(idx: Int) { - viewModelScope.launch { - sessionDao.updateCurrentPage(sessionID, idx) - } - } - - fun setShowExportOptionsPopup(show: Boolean) { - _scanningScreenData.showExportOptions.value = show - } - - fun setExportPopupPosition(x: Int, y: Int, height: Int) { - _scanningScreenData.exportOptionsPopupPosition.value = Triple(x, y, height) - } - - fun setFileToSave(file: File) { - _scanningScreenData.sourceFileToSave.value = file - } - - fun setShowSaveOptionsPopup(show: Boolean) { - _scanningScreenData.showSaveOptions.value = show - } - - fun setSavePopupPosition(x: Int, y: Int, height: Int) { - _scanningScreenData.savePopupPosition.value = Triple(x, y, height) - } - - fun setLoadingText(stringRes: Int?) { - _scanningScreenData.stateProgressStringRes.value = stringRes - } - - fun setScanSettingsMenuOpen(value: Boolean) { - _scanningScreenData.scanSettingsMenuOpen.value = value - } - - fun setConfirmDialogShown(value: Boolean) { - _scanningScreenData.confirmDialogShown.value = value - } - - fun setDeletePageDialogShown(value: Boolean) { - _scanningScreenData.confirmPageDeleteDialogShown.value = value - } - - fun setError(error: String?, titleResource: Int? = null, errorIcon: Int? = null) { - _scanningScreenData.error.value = ErrorDescription( - titleResource, - errorIcon, - error - ) - } - - fun rotatePage(pageId: Uuid) { - viewModelScope.launch { - rotatePageInternal(pageId) - } - } - - suspend fun rotatePageInternal(pageId: Uuid) { - val scannedPage = scannedPageDao.getByScanId(pageId) - if (scannedPage == null || _scanningScreenData.isRotating.value) { - return - } - _scanningScreenData.isRotating.value = true - setLoadingText(R.string.rotating_page) - try { - val pagePath = - scannedPage.filePath - val pageFile = File(pagePath) - - Timber.d("Decoding $pagePath") - val originalBitmap = withContext(Dispatchers.IO) { BitmapFactory.decodeFile(pagePath) } - if (originalBitmap == null) { - Timber.e("Failed to decode bitmap for $pagePath") - setLoadingText(null) - _scanningScreenData.isRotating.value = false - return - } - Timber.d("Rotating $pagePath") - val rotatedBitmap = withContext(Dispatchers.IO) { originalBitmap.rotateBy90() } - originalBitmap.recycle() - - val editedImageName = pageFile.getEditedImageName() - val newFile = File(application.filesDir, editedImageName) - - Timber.d("Saving rotated $pagePath") - withContext(Dispatchers.IO) { - rotatedBitmap.saveAsJPEG(newFile) - } - rotatedBitmap.recycle() - tmpFileDao.insertAll( - TempFile( - ownerSessionId = sessionID, - path = pagePath - ) - ) - - Timber.d("Finished saving rotated $pagePath") - - Timber.d("Updating DB state after rotation") - scannedPageDao.update( - scannedPage.copy( - rotation = scannedPage.rotation.toggleRotation(), - filePath = newFile.absolutePath - ) - ) - } finally { - setLoadingText(null) - _scanningScreenData.isRotating.value = false - } - } - - suspend fun saveUpdatedScanSettingsUiData(newData: ScanSettingsEnterableDataV1?) { - Timber.d("Settings ui data updated $newData") - sessionDao.updateScanSettingsUiData(sessionID, newData) - } - - suspend fun setScannerCapabilities(caps: CommonScannerCapabilities) { - _scanningScreenData.capabilities.value = caps - val storedSession = sessionDao.getSessionById(sessionID) - - Timber.d("Stored session: $storedSession") - - val updateSettings: suspend (CommonScanSettingsEditor.() -> Unit) -> Unit = { edit -> - db.useWriterConnection { - it.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 = newSettings - ) - - Timber.d("Settings updated $newSettings") - - sessionDao.update(newSession) - } - } - } - - if (storedSession != null) { - _scanningScreenData.scanSettingsVM.value = ScanSettingsComposableStateHolder( - MutableStateFlow(caps).asStateFlow(), - session.map { it?.currentScanSettings ?: storedSession.currentScanSettings!! } - .stateIn(viewModelScope, SharingStarted.Lazily, storedSession.currentScanSettings!!), - storedSession.currentSettingsUIData?.copy(), - updateSettings, - null, - viewModelScope, - koinScope.get(), - koinScope.get(), - koinScope.get(), - koinScope.get() - ) - } else { - // Try to load saved scan settings first, fallback to defaults if none exist - val savedSettings = lastUsedScanSettingsRepo.getLastUsedScanSettings() - - val lastUsedScanSettings = savedSettings?.lastUsedScanSettings - val lastUsedScanSettingsEnterable = savedSettings?.lastUsedScanSettingsEnterable - - val initialSettings = if (lastUsedScanSettings != null) { - val editor = CommonScanSettingsEditor( - caps, - lastUsedScanSettings - ) - editor.build() - } else { - val editor = CommonScanSettingsEditor( - caps, - CommonScanSettings(setting = ScanSettingsMap.empty()) - ) - initialScanSettingsProvider.applyDefaults(editor, caps) - editor.build() - } - - sessionDao.insertAll( - Session( - sessionID, - initialSettings, - lastUsedScanSettingsEnterable - ) - ) - - _scanningScreenData.scanSettingsVM.value = ScanSettingsComposableStateHolder( - MutableStateFlow(caps).asStateFlow(), - session.map { it?.currentScanSettings ?: initialSettings } - .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), - lastUsedScanSettingsEnterable, - updateSettings, - { initialSettings }, - viewModelScope, - koinScope.get(), - koinScope.get(), - koinScope.get(), - koinScope.get() - ) - } - - // Subscribe to scan settings ui data changes so that we can save them to the database - _scanningScreenData.scanSettingsVM.value?.uiState?.onEach { - saveUpdatedScanSettingsUiData(it) - }?.launchIn(viewModelScope) - } - - fun swapTwoPages(page1: ScannedPage, page2: ScannedPage) { - viewModelScope.launch { - scannedPageDao.swapPages(page1, page2) - } - } - - fun removeScan(page: ScannedPage) { - viewModelScope.launch { - withContext(Dispatchers.IO) { - Files.delete(Path(page.filePath)) - } - scannedPageDao.delete(page) - } - } - - fun scan(snackBarScope: CoroutineScope, snackBarHostState: SnackbarHostState) { - viewModelScope.launch { - val currentSettings = session.value?.currentScanSettings - - if (currentSettings == null) { - Timber.e("Could not start scan job. Current scan settings null") - return@launch - } - - if (isScanJobRunning.value) { - Timber.e("Job still running") - snackbarErrorRetrievingPage( - application.getString(R.string.job_still_running), - snackBarScope, - application, - snackBarHostState, - false - ) - return@launch - } - - startScanUseCase.startScan( - ownerSessionId = sessionID, - scannerHandle = scannerHandle, - scanSettings = currentSettings, - connectionSettings = scannerConnectionSettings() - ) - } - } - - fun deleteSession(finished: () -> Unit) { - viewModelScope.launch { - val filePaths = mutableListOf() - val tmpPaths = mutableListOf() - - db.useWriterConnection { - it.immediateTransaction { - val scannedPages = scannedPageDao.getAllForSession(sessionID) - val tmpFiles = tmpFileDao.getFilesBySessionId(sessionID) - - filePaths += scannedPages.map { it.filePath } - tmpPaths += tmpFiles.map { it.path } - - sessionDao.deleteById(sessionID) - } - } - - withContext(Dispatchers.IO) { - filePaths.forEach { - Files.deleteIfExists(Path(it)) - } - tmpPaths.forEach { - Files.deleteIfExists(Path(it)) - } - } - - finished() - } - } - - fun doPdfExport(context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null) { - viewModelScope.launch { - doPdfExportInternal(context, onError, saveFileLauncher) - } - } - - private suspend fun doPdfExportInternal( - context: Context, - onError: (String) -> Unit, - saveFileLauncher: ActivityResultLauncher? = null - ) { - val currentScans = scannedPages.value - val scannerCapsNullable = scanningScreenData.capabilities - val scannerCaps = if (scannerCapsNullable == null) { - onError(application.getString(R.string.scannercapabilities_null)) - return - } else { - scannerCapsNullable - } - - if (currentScans.isEmpty()) { - onError(application.getString(R.string.no_scans_yet)) - return - } - if (isScanJobRunning.value) { - onError(application.getString(R.string.job_still_running)) - return - } - - setLoadingText(R.string.exporting) - - val parentDir = File(application.filesDir, "exports") - if (!parentDir.exists()) { - parentDir.mkdir() - } - - val nameRoot = "pdfexport-${ - LocalDateTime.now() - .format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH_mm_ss_SSS")) - }" - - var pageCounter = 0 - - val chunkSize = try { - appSettingsRepository.getAppSettings().chunkSizeForPDFExport - } catch (exception: Exception) { - Timber.e("doPdfExport couldn't access app settings. Returning default value: $exception") - AppSettings().chunkSizeForPDFExport - } - - val chunks = currentScans.chunked(chunkSize) - - chunks.forEachIndexed { index, chunk -> - val pdfFile = File( - parentDir, - "$nameRoot-$index.pdf" - ) - PdfWriter(pdfFile).use { writer -> - PdfDocument(writer).use { pdf -> - Document(pdf).use { document -> - chunk.forEachIndexed { i, scan -> - val imageData = ImageDataFactory.create(scan.filePath) - - val rotated = scan.rotation == ScanRelativeRotation.Rotated - - val fallbackInputSourceCaps = scannerCaps.inputSources.first() - - val fallbackResolution = fallbackInputSourceCaps - .furtherOptions[ScannerConcept.ScanResolution]!! - .defaultValue - - 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 - - // pts are 1/72th inch - val widthPts = (imageData.width / rotationCorrectedXRes.toFloat()).inches().toPoints().value - val heightPts = (imageData.height / rotationCorrectedYRes.toFloat()).inches().toPoints().value - - pdf.addNewPage( - PageSize( - widthPts.toFloat(), - heightPts.toFloat() - ) - ) - - val imageElem = Image(imageData) - imageElem.setFixedPosition(i + 1, 0f, 0f) - imageElem.setHeight(heightPts.toFloat()) - imageElem.setWidth(widthPts.toFloat()) - - document.add(imageElem) - - pageCounter++ - Timber.d("Added page $pageCounter to PDF") - } - } - } - } - } - - val digitsNeeded = chunks.size.toString().length - val tempPdfFiles = List(chunks.size) { index -> - File(parentDir, "$nameRoot-${index.toString().padStart(digitsNeeded, '0')}.pdf") - } - - tempPdfFiles.forEach { addTempFile(it) } - - var outputFile: File - if (tempPdfFiles.size > 1) { - outputFile = File(parentDir, "$nameRoot.zip") - zipFiles(tempPdfFiles, outputFile) - addTempFile(outputFile) - } else { - outputFile = tempPdfFiles[0] - } - - setLoadingText(null) - - val mimetype = if (tempPdfFiles.size > 1) "application/zip" else "application/pdf" - - if (saveFileLauncher == null) { - val share = Intent(Intent.ACTION_SEND) - share.type = mimetype - share.putExtra( - Intent.EXTRA_STREAM, - FileProvider.getUriForFile( - application, - "${application.packageName}.fileprovider", - outputFile - ) - ) - context.startActivity(share) - } else { - setFileToSave(outputFile) - saveFileLauncher.launch(outputFile.name) - } - } - - fun doZipExport(context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null) { - viewModelScope.launch { - doZipExportInternal( - context, - onError, - saveFileLauncher - ) - } - } - - private suspend fun doZipExportInternal( - context: Context, - onError: (String) -> Unit, - saveFileLauncher: ActivityResultLauncher? = null - ) { - val currentScans = scannedPages.value - if (currentScans.isEmpty()) { - onError(application.getString(R.string.no_scans_yet)) - return - } - if (isScanJobRunning.value) { - onError(application.getString(R.string.job_still_running)) - return - } - - setLoadingText(R.string.exporting) - - val parentDir = File(application.filesDir, "exports") - if (!parentDir.exists()) { - parentDir.mkdir() - } - - val name = "zipexport-${ - LocalDateTime.now() - .format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH_mm_ss_SSS")) - }.zip" - - val zipOutputFile = File(parentDir, name) - - var counter = 0 - val digitsNeeded = currentScans.size.toString().length - withContext(Dispatchers.IO) { - zipFiles( - currentScans.map { File(it.filePath) }, - zipOutputFile, - { - counter++ - // TODO: This will never work correctly because extension is always empty - "scan-${counter.toString().padStart(digitsNeeded, '0')}.${it.extension}" - } - ) - } - - setLoadingText(null) - - addTempFile(zipOutputFile) - - if (saveFileLauncher == null) { - val share = Intent(Intent.ACTION_SEND) - share.type = "application/zip" - share.putExtra( - Intent.EXTRA_STREAM, - FileProvider.getUriForFile( - application, - "${application.packageName}.fileprovider", - zipOutputFile - ) - ) - context.startActivity(share) - } else { - setFileToSave(zipOutputFile) - saveFileLauncher.launch(zipOutputFile.name) - } - } - - fun scannerConnectionSettings() = ScannerConnectionSettings( - timeout.toULong(), - timeout.toULong(), - certificateValidationDisabled, - withDebugInterceptor - ) - - fun retrieveScannerCapabilities() = viewModelScope.launch { - val connectionSettings = scannerConnectionSettings() - - val scannerCapabilities = scannerHandle.scannerCapabilities(connectionSettings) - - if (scannerCapabilities !is ScannerCapabilitiesResult.Success) { - Timber.e("Error while retrieving ScannerCapabilities: $scannerCapabilities") - setError("$scannerCapabilities") - return@launch - } - - setScannerCapabilities(scannerCapabilities.scannerCapabilities) - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/logs/FileLogger.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/logs/FileLogger.kt deleted file mode 100644 index 9641039f..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/logs/FileLogger.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.chrisimx.scanbridge.logs - -import java.io.BufferedWriter -import java.time.format.DateTimeFormatter -import timber.log.Timber - -class FileLogger(val output: BufferedWriter) : Timber.Tree() { - override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - val date = DateTimeFormatter.ISO_DATE_TIME.format(java.time.LocalDateTime.now()) - try { - output.write("$date - $priority - $tag - $message - $t\n") - } catch (e: Exception) { - e.printStackTrace() - } - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/LastRouteRepositoryAutoMigration.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/LastRouteRepositoryAutoMigration.kt new file mode 100644 index 00000000..af73f841 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/LastRouteRepositoryAutoMigration.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.migrations + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.savelastroute.LastRouteRepository + +class LastRouteRepoAutoMigration(val a: LastRouteRepository, val b: LastRouteRepository, override val migrationId: String) : Migration { + override suspend fun migrate(db: ScanBridgeDb): Boolean { + val toBeMigrated = a.getLastRoute() + b.setLastRoute(toBeMigrated) + return true + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/MigrationRegistry.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/MigrationRegistry.kt index a4e1839e..f4053bed 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/MigrationRegistry.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/MigrationRegistry.kt @@ -1,8 +1,7 @@ package io.github.chrisimx.scanbridge.migrations import io.github.chrisimx.scanbridge.repositories.DatastoreLastRouteRepository -import io.github.chrisimx.scanbridge.repositories.LastRouteRepoAutoMigration -import io.github.chrisimx.scanbridge.repositories.RoomLastRouteRepository +import io.github.chrisimx.scanbridge.savelastroute.RoomLastRouteRepository import org.koin.dsl.bind import org.koin.dsl.module import org.koin.plugin.module.dsl.create diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/RoomBackedMigrationExecutor.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/RoomBackedMigrationExecutor.kt index c6942117..572de036 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/RoomBackedMigrationExecutor.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/RoomBackedMigrationExecutor.kt @@ -2,7 +2,7 @@ package io.github.chrisimx.scanbridge.migrations import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.entities.ExecutedMigrationToRoom -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koin.mp.KoinPlatform.getKoin diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreLastRouteRepository.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreLastRouteRepository.kt index 2a6d61c2..5fb80f04 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreLastRouteRepository.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreLastRouteRepository.kt @@ -2,7 +2,7 @@ package io.github.chrisimx.scanbridge.repositories import android.content.Context import com.google.protobuf.StringValue -import io.github.chrisimx.scanbridge.LastRouteRepository +import io.github.chrisimx.scanbridge.savelastroute.LastRouteRepository import io.github.chrisimx.scanbridge.datastore.lastRouteStore import io.github.chrisimx.scanbridge.proto.copy import io.github.chrisimx.scanbridge.proto.lastRouteOrNull diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreShownMessagesRepository.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreShownMessagesRepository.kt index 9df8479b..7eb8a27c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreShownMessagesRepository.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/DatastoreShownMessagesRepository.kt @@ -1,7 +1,9 @@ package io.github.chrisimx.scanbridge.repositories +import android.content.Context import androidx.datastore.core.DataStore import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider +import io.github.chrisimx.scanbridge.datastore.shownMessagesStore import io.github.chrisimx.scanbridge.proto.ShownMessages import io.github.chrisimx.scanbridge.proto.copy import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository @@ -9,8 +11,10 @@ import io.github.chrisimx.scanbridge.startupmessages.StartupMessage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -class DatastoreShownMessagesRepository(val shownMessagesDataStore: DataStore, val buildInfoProvider: BuildInfoProvider) : +class DatastoreShownMessagesRepository(context: Context, val buildInfoProvider: BuildInfoProvider) : ShownStartupMessagesRepository { + val shownMessagesDataStore = context.shownMessagesStore + override fun getWasShownFlow(message: StartupMessage): Flow = shownMessagesDataStore.data.map { !message.editions.contains(buildInfoProvider.edition) || when (message) { StartupMessage.THANKS_FOR_PURCHASE -> it.thankPlayOne diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutionEntryPoint.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutionEntryPoint.kt new file mode 100644 index 00000000..e9a9e5e5 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutionEntryPoint.kt @@ -0,0 +1,13 @@ +package io.github.chrisimx.scanbridge.scan + +import android.app.Application +import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService +import io.github.chrisimx.scanbridge.scanning.ScanExecutionEntryPoint + +class AndroidScanExecutionEntryPoint( + val application: Application +) : ScanExecutionEntryPoint { + override fun start() { + ScanJobForegroundService.startService(application) + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt index e100d31b..4959d6a0 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt @@ -1,53 +1,2 @@ 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/DebugLogService.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/DebugLogService.kt deleted file mode 100644 index 011ad22b..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/DebugLogService.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.chrisimx.scanbridge.services - -import android.net.Uri - -interface DebugLogService { - fun clear() - fun saveToFile(uri: Uri) - - fun flush() -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/FileDebugLogService.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/FileDebugLogService.kt deleted file mode 100644 index ba7ed702..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/FileDebugLogService.kt +++ /dev/null @@ -1,104 +0,0 @@ -package io.github.chrisimx.scanbridge.services - -import android.app.Application -import android.net.Uri -import io.github.chrisimx.scanbridge.BuildConfig -import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository -import io.github.chrisimx.scanbridge.logs.FileLogger -import java.io.BufferedWriter -import java.io.File -import java.io.FileWriter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import timber.log.Timber - -class FileDebugLogService(appSettings: AppSettingsRepository, val application: Application) : DebugLogService { - - val scope = CoroutineScope(Dispatchers.Main) - - private var debugWriter: BufferedWriter? = null - private var tree: Timber.Tree? = null - - private val debugLogActive = appSettings.getWriteDebugLogsFlow() - - init { - debugLogActive.onEach { - if (it) { - start() - } else { - stop() - } - }.launchIn(scope) - } - - private fun start() { - val debugDir = File(application.filesDir, "debug") - if (!debugDir.exists()) { - debugDir.mkdir() - } - val output = File(debugDir, "debug.txt") - if (!output.exists()) { - output.createNewFile() - } - debugWriter = BufferedWriter(FileWriter(output, true)) - tree = FileLogger(debugWriter!!) - Timber.forest().filterIsInstance().forEach { - Timber.d("Old tree removed $it") - it.output.close() - Timber.uproot(it) - } - Timber.plant(tree!!) - Timber.i( - "Debug logging starts with ScanBridge (${BuildConfig.VERSION_NAME}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.GIT_COMMIT_HASH}, ${BuildConfig.BUILD_TYPE}, ${BuildConfig.FLAVOR})" - ) - } - - private fun stop() { - Timber.i( - "Debug logging stops with ScanBridge (${BuildConfig.VERSION_NAME}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.GIT_COMMIT_HASH}, ${BuildConfig.BUILD_TYPE}, ${BuildConfig.FLAVOR})" - ) - tree?.let { - Timber.uproot(it) - tree = null - } - debugWriter?.close() - debugWriter = null - } - - override fun clear() { - Timber.d("Clearing debug log") - tree?.let { - Timber.uproot(it) - tree = null - } - debugWriter?.close() - debugWriter = null - - val debugDir = File(application.filesDir, "debug") - if (debugDir.exists()) { - val debugFile = File(debugDir, "debug.txt") - if (debugFile.exists()) { - debugFile.delete() - } - } - - start() - } - - override fun flush() { - debugWriter?.flush() - } - - override fun saveToFile(uri: Uri) { - val debugDir = File(application.filesDir, "debug") - val debugFile = File(debugDir, "debug.txt") - - application.contentResolver.openOutputStream(uri)?.use { outputStream -> - debugFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt deleted file mode 100644 index 8ac9b89a..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.chrisimx.scanbridge.uicomponents - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import io.github.chrisimx.scanbridge.R - -@Composable -fun ExportSettingsPopup( - exportOptionsPopupPosition: Triple?, - exportOptionsWidth: Int, - alpha: Float, - onDismiss: () -> Unit, - updateWidth: (Int) -> Unit, - onExportPdf: () -> Unit, - onExportArchive: () -> Unit -) { - Popup( - alignment = Alignment.TopStart, - offset = IntOffset( - exportOptionsPopupPosition?.first?.minus(exportOptionsWidth / 4) ?: 0, - exportOptionsPopupPosition?.second?.minus(exportOptionsPopupPosition.third) ?: 0 - ), - onDismissRequest = { onDismiss() } - ) { - ElevatedCard( - modifier = Modifier - .onGloballyPositioned { coordinates -> - updateWidth(coordinates.size.width) - } - .graphicsLayer { - this.alpha = alpha - }, - shape = RoundedCornerShape(30.dp) - ) { - Row { - IconButton(onClick = { onExportPdf() }) { - Icon( - painterResource(R.drawable.baseline_picture_as_pdf_24), - contentDescription = stringResource(R.string.export_pdf) - ) - } - IconButton(onClick = { onExportArchive() }) { - Icon( - painterResource(R.drawable.baseline_image_24), - contentDescription = stringResource(R.string.export_as_archive) - ) - } - } - } - } -} 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 deleted file mode 100644 index e67cb734..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ /dev/null @@ -1,58 +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.util - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import io.github.chrisimx.esclkt.JobState -import io.github.chrisimx.scanbridge.R -import io.github.chrisimx.scanbridge.model.UIInputSourceType - -fun JobState?.toJobStateString(context: Context): String = when (this) { - JobState.Canceled -> context.getString(R.string.job_canceled) - JobState.Aborted -> context.getString(R.string.job_aborted_because_error) - JobState.Completed -> context.getString(R.string.job_completed_successfully) - JobState.Pending -> context.getString(R.string.job_still_pending) - JobState.Processing -> context.getString(R.string.job_pages_still_processed) - null -> context.getString(R.string.job_state_cannot_be_retrieved) -} - -@Composable -fun UIInputSourceType.toReadableString(): String = when (this) { - UIInputSourceType.PLATEN -> - stringResource(R.string.platen) - - UIInputSourceType.ADF -> stringResource(R.string.adf) -} - -/** - * 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/ImageUtil.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ImageUtil.kt index 08f3efa3..73da3c98 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ImageUtil.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ImageUtil.kt @@ -6,27 +6,6 @@ import androidx.compose.ui.geometry.Rect import java.io.File import kotlin.math.roundToInt -fun Bitmap.rotateBy90(): Bitmap { - val matrix = Matrix().apply { postRotate(90F) } - return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) -} - -fun Bitmap.cropWithRect(cropRect: Rect): Bitmap { - val startX = cropRect.left * this.width - val startY = cropRect.top * this.height - - val width = (cropRect.right - cropRect.left) * this.width - val height = (cropRect.bottom - cropRect.top) * this.height - - return Bitmap.createBitmap( - this, - startX.roundToInt(), - startY.roundToInt(), - width.roundToInt(), - height.roundToInt() - ) -} - fun Bitmap.saveAsJPEG(file: File) { file.outputStream().use { this.compress(Bitmap.CompressFormat.JPEG, 100, it) 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 deleted file mode 100644 index f1d77c42..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.chrisimx.scanbridge.util - -import java.io.File - -fun File.getEditedImageName(): String { - val baseName = this.name.substringBefore(" edit-") - - return "$baseName edit-${System.currentTimeMillis()}" -} diff --git a/androidApp/src/play/java/io/github/chrisimx/scanbridge/LicenseErrorStringMapping.kt b/androidApp/src/play/java/io/github/chrisimx/scanbridge/LicenseErrorStringMapping.kt index 4b71e08c..d61f3509 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/LicenseErrorStringMapping.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/LicenseErrorStringMapping.kt @@ -2,21 +2,37 @@ package io.github.chrisimx.scanbridge import android.content.Context import com.google.android.vending.licensing.LicenseValidationResultCode +import org.jetbrains.compose.resources.getString +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.application_license_check_permission_missing +import scanbridge.composeui.generated.resources.application_license_public_key_invalid +import scanbridge.composeui.generated.resources.google_license_server_failure +import scanbridge.composeui.generated.resources.google_license_server_over_quota +import scanbridge.composeui.generated.resources.google_license_server_unreachable +import scanbridge.composeui.generated.resources.google_license_user_undetermined +import scanbridge.composeui.generated.resources.google_licensing_server_invalid_response +import scanbridge.composeui.generated.resources.google_play_services_not_running +import scanbridge.composeui.generated.resources.licensed +import scanbridge.composeui.generated.resources.licensed_but_data_got_lost +import scanbridge.composeui.generated.resources.licensed_package_has_invalid_name +import scanbridge.composeui.generated.resources.licensed_package_not_on_google_play +import scanbridge.composeui.generated.resources.licensing_unknown_server_response_code +import scanbridge.composeui.generated.resources.not_licensed -fun LicenseValidationResultCode.asLocalizedString(context: Context): String = when (this) { - LicenseValidationResultCode.LICENSED -> context.getString(R.string.licensed) - LicenseValidationResultCode.NOT_LICENSED -> context.getString(R.string.not_licensed) - LicenseValidationResultCode.LICENSED_OLD_KEY -> context.getString(R.string.licensed) - LicenseValidationResultCode.ERROR_NOT_MARKET_MANAGED -> context.getString(R.string.licensed_package_not_on_google_play) - LicenseValidationResultCode.ERROR_SERVER_FAILURE -> context.getString(R.string.google_license_server_failure) - LicenseValidationResultCode.ERROR_OVER_QUOTA -> context.getString(R.string.google_license_server_over_quota) - LicenseValidationResultCode.ERROR_CONTACTING_SERVER -> context.getString(R.string.google_license_server_unreachable) - LicenseValidationResultCode.ERROR_INVALID_PACKAGE_NAME -> context.getString(R.string.licensed_package_has_invalid_name) - LicenseValidationResultCode.ERROR_NON_MATCHING_UID -> context.getString(R.string.google_license_user_undetermined) - LicenseValidationResultCode.GOOGLE_PLAY_SERVICE_CONNECTION_FAILED -> context.getString(R.string.google_play_services_not_running) - LicenseValidationResultCode.RESPONSE_DATA_UNEXPECTEDLY_NULL -> context.getString(R.string.licensed_but_data_got_lost) - LicenseValidationResultCode.ERROR_INVALID_PUBLIC_KEY -> context.getString(R.string.application_license_public_key_invalid) - LicenseValidationResultCode.ERROR_MISSING_PERMISSION -> context.getString(R.string.application_license_check_permission_missing) - LicenseValidationResultCode.ERROR_INVALID_SERVER_RESPONSE -> context.getString(R.string.google_licensing_server_invalid_response) - LicenseValidationResultCode.UNKNOWN_RESPONSE_CODE -> context.getString(R.string.licensing_unknown_server_response_code) +suspend fun LicenseValidationResultCode.asLocalizedString(): String = when (this) { + LicenseValidationResultCode.LICENSED -> getString(Res.string.licensed) + LicenseValidationResultCode.NOT_LICENSED -> getString(Res.string.not_licensed) + LicenseValidationResultCode.LICENSED_OLD_KEY -> getString(Res.string.licensed) + LicenseValidationResultCode.ERROR_NOT_MARKET_MANAGED -> getString(Res.string.licensed_package_not_on_google_play) + LicenseValidationResultCode.ERROR_SERVER_FAILURE -> getString(Res.string.google_license_server_failure) + LicenseValidationResultCode.ERROR_OVER_QUOTA -> getString(Res.string.google_license_server_over_quota) + LicenseValidationResultCode.ERROR_CONTACTING_SERVER -> getString(Res.string.google_license_server_unreachable) + LicenseValidationResultCode.ERROR_INVALID_PACKAGE_NAME -> getString(Res.string.licensed_package_has_invalid_name) + LicenseValidationResultCode.ERROR_NON_MATCHING_UID -> getString(Res.string.google_license_user_undetermined) + LicenseValidationResultCode.GOOGLE_PLAY_SERVICE_CONNECTION_FAILED -> getString(Res.string.google_play_services_not_running) + LicenseValidationResultCode.RESPONSE_DATA_UNEXPECTEDLY_NULL -> getString(Res.string.licensed_but_data_got_lost) + LicenseValidationResultCode.ERROR_INVALID_PUBLIC_KEY -> getString(Res.string.application_license_public_key_invalid) + LicenseValidationResultCode.ERROR_MISSING_PERMISSION -> getString(Res.string.application_license_check_permission_missing) + LicenseValidationResultCode.ERROR_INVALID_SERVER_RESPONSE -> getString(Res.string.google_licensing_server_invalid_response) + LicenseValidationResultCode.UNKNOWN_RESPONSE_CODE -> getString(Res.string.licensing_unknown_server_response_code) } diff --git a/androidApp/src/play/java/io/github/chrisimx/scanbridge/SignupScreen.kt b/androidApp/src/play/java/io/github/chrisimx/scanbridge/SignupScreen.kt index 807f73bf..557f15bc 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/SignupScreen.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/SignupScreen.kt @@ -26,13 +26,17 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,35 +54,67 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.android.vending.licensing.LicenseValidationResultCode import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.zammadapi.AccountVerificationClient import io.github.chrisimx.scanbridge.zammadapi.models.AccountCreationRequest import io.github.chrisimx.scanbridge.zammadapi.models.VIPCreationResult +import io.github.chrisimx.scanbridge.zammadapi.models.toLocalizedString import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.error +import scanbridge.composeui.generated.resources.please_accept_the_privacy_policy_to_sign_up import timber.log.Timber -class SignupViewModel(application: Application) : AndroidViewModel(application) { - fun signUp(name: CharSequence, email: CharSequence, snackbarHostState: SnackbarHostState, onSuccess: () -> Unit) { +sealed class SignupError { + object PrivacyPolicyNotAccepted : SignupError() + data class AccountCreationError(val error: VIPCreationResult) : SignupError() + data class LicenseError(val error: LicenseValidationResultCode) : SignupError() + data class OtherError(val error: Throwable) : SignupError() +} + +class SignupViewModel( + application: Application, + val ownershipProofService: OwnershipProofService +) : AndroidViewModel(application) { + private val _errorStream = MutableSharedFlow() + val errorStream = _errorStream.asSharedFlow() + + private val _privacyPolicyAccepted = MutableStateFlow(false) + val privacyPolicyAccepted = _privacyPolicyAccepted.asStateFlow() + + fun setPrivacyPolicyAccepted(accepted: Boolean) { + _privacyPolicyAccepted.value = accepted + } + + fun signUp(name: CharSequence, email: CharSequence, onSuccess: () -> Unit) { viewModelScope.launch { + if (!privacyPolicyAccepted.value) { + _errorStream.emit(SignupError.PrivacyPolicyNotAccepted) + return@launch + } + withContext(Dispatchers.IO) { try { val api = AccountVerificationClient.RetrofitClient.api val googleChallenge = api.getGoogleChallenge() - val ops = OwnershipProofService(application) - val ownershipProofResult = ops.requestOwnershipProof(googleChallenge.nonce) + val ownershipProofResult = ownershipProofService.requestOwnershipProof(googleChallenge.nonce) if (ownershipProofResult is LicensingRequestResult.Error) { - Timber.e(ownershipProofResult.errorCode.asLocalizedString(application)) - snackBarError( - application.getString(R.string.error) + ": " + ownershipProofResult.errorCode.asLocalizedString(application), - viewModelScope, - application, - snackbarHostState + Timber.e(ownershipProofResult.errorCode.toString()) + _errorStream.emit( + SignupError.LicenseError(ownershipProofResult.errorCode) ) + return@withContext } else if (ownershipProofResult is LicensingRequestResult.OwnershipProof) { val rawProof = ownershipProofResult.responseData.originalResponse @@ -102,26 +138,16 @@ class SignupViewModel(application: Application) : AndroidViewModel(application) Timber.e( "%s: %s", application.getString(R.string.error), - application.getString(accountCreationResult.result.message) - ) - snackBarError( - application.getString(R.string.error) + ": " + application.getString(accountCreationResult.result.message), - viewModelScope, - application, - snackbarHostState + accountCreationResult.result.toLocalizedString() ) + _errorStream.emit(SignupError.AccountCreationError(accountCreationResult.result)) return@withContext } onSuccess() } } catch (error: Exception) { Timber.e(error) - snackBarError( - application.getString(R.string.error) + ": " + error.message, - viewModelScope, - application, - snackbarHostState - ) + _errorStream.emit(SignupError.OtherError(error)) } } } @@ -133,9 +159,12 @@ class SignupViewModel(application: Application) : AndroidViewModel(application) fun SignupScreen(modifier: Modifier, onBack: () -> Unit, onSuccess: () -> Unit) { val viewModel: SignupViewModel = viewModel() val snackbarHostState = remember { SnackbarHostState() } - var privacyPolicyAccepted by remember { mutableStateOf(false) } + val privacyPolicyAccepted by viewModel.privacyPolicyAccepted.collectAsState() + val context = LocalContext.current val localTextStyle = LocalTextStyle.current + val coroutineScope = rememberCoroutineScope() + val clipboard = LocalClipboard.current val privacyPolicyLink: AnnotatedString = remember { buildAnnotatedString { @@ -158,6 +187,17 @@ fun SignupScreen(modifier: Modifier, onBack: () -> Unit, onSuccess: () -> Unit) } } + LaunchedEffect(Unit) { + viewModel.errorStream.collect { error -> + snackBarError( + error.errorText(), + coroutineScope, + snackbarHostState, + clipboard = clipboard + ) + } + } + Box( modifier = modifier .fillMaxSize(), @@ -204,30 +244,17 @@ fun SignupScreen(modifier: Modifier, onBack: () -> Unit, onSuccess: () -> Unit) .padding(horizontal = 20.dp) .toggleable( value = privacyPolicyAccepted, - onValueChange = { privacyPolicyAccepted = it }, + onValueChange = viewModel::setPrivacyPolicyAccepted, role = Role.Checkbox ) ) { - Checkbox(privacyPolicyAccepted, { - privacyPolicyAccepted = it - }) + Checkbox(privacyPolicyAccepted, viewModel::setPrivacyPolicyAccepted) Text(privacyPolicyLink, textAlign = TextAlign.Justify) } Row { Button({ - if (!privacyPolicyAccepted) { - snackBarError( - context.getString(R.string.please_accept_the_privacy_policy_to_sign_up), - viewModel.viewModelScope, - context, - snackbarHostState, - false - ) - return@Button - } - - viewModel.signUp(nameState.text, emailState.text, snackbarHostState, onSuccess) + viewModel.signUp(nameState.text, emailState.text, onSuccess) }, modifier = Modifier.padding(20.dp)) { Text(stringResource(R.string.signup)) } @@ -252,6 +279,15 @@ fun SignupScreen(modifier: Modifier, onBack: () -> Unit, onSuccess: () -> Unit) } } +suspend fun SignupError.errorText(): String { + return when (this) { + is SignupError.AccountCreationError -> getString(Res.string.error) + ": " + this.error.toLocalizedString() + is SignupError.LicenseError -> getString(Res.string.error) + ": " + this.error.asLocalizedString() + is SignupError.OtherError -> getString(Res.string.error) + ": " + this.error + SignupError.PrivacyPolicyNotAccepted -> getString(Res.string.please_accept_the_privacy_policy_to_sign_up) + } +} + @Composable @Preview() fun SignupScreenPreview() { 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 1af2766d..f84e6487 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/StartupTabDefinitions.kt @@ -6,42 +6,52 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.ExperimentalMaterial3Api +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.discovery +import scanbridge.composeui.generated.resources.header_scannerbrowser +import scanbridge.composeui.generated.resources.settings +import scanbridge.composeui.generated.resources.support -@OptIn(ExperimentalMaterial3Api::class) -val STARTUP_TABS = listOf( - StartupScreen( - R.string.discovery, - R.string.header_scannerbrowser, - Icons.Filled.Home, - Icons.Outlined.Home, - true, - { innerPadding, navController, showCustomDialog, setShowCustomDialog -> - ScannerBrowser( - innerPadding, - navController, - showCustomDialog, - setShowCustomDialog - ) - } - ), - StartupScreen( - R.string.settings, - R.string.settings, - Icons.Filled.Settings, - Icons.Outlined.Settings, - false, - { innerPadding, _, _, _ -> - AppSettingsScreen(innerPadding) - } - ), - StartupScreen( - R.string.support, - R.string.support, - BaselineHelp24, - OutlineHelp24, - false, - { innerPadding, _, _, _ -> - SupportScreen(innerPadding) - } +class PlayStartupTabsProvider : StartupTabsProvider { + @OptIn(ExperimentalMaterial3Api::class) + override fun getStartupTabs(): List = listOf( + StartupTabDefinition( + Res.string.discovery, + Res.string.header_scannerbrowser, + Icons.Filled.Home, + Icons.Outlined.Home, + true, + { innerPadding, navController, showCustomDialog, setShowCustomDialog -> + ScannerBrowser( + innerPadding, + navController, + showCustomDialog, + setShowCustomDialog + ) + } + ), + StartupTabDefinition( + Res.string.settings, + Res.string.settings, + Icons.Filled.Settings, + Icons.Outlined.Settings, + false, + { innerPadding, _, _, _ -> + AppSettingsScreen(innerPadding) + } + ), + StartupTabDefinition( + Res.string.support, + Res.string.support, + BaselineHelp24, + OutlineHelp24, + false, + { innerPadding, _, _, _ -> + SupportScreen(innerPadding) + } + ) ) -) + +} + +val STARTUP_TABS_PROVIDER = PlayStartupTabsProvider() diff --git a/androidApp/src/play/java/io/github/chrisimx/scanbridge/SupportScreen.kt b/androidApp/src/play/java/io/github/chrisimx/scanbridge/SupportScreen.kt index d67d1754..3259af2e 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/SupportScreen.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/SupportScreen.kt @@ -9,8 +9,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import io.github.chrisimx.scanbridge.uicomponents.FullScreenError +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.internet_connection_needed +import scanbridge.composeui.generated.resources.twotone_wifi_find_24 enum class SupportScreenStates { SetupScreen, @@ -26,8 +29,8 @@ fun SupportScreen(innerPadding: PaddingValues) { AnimatedContent(isInternetConnected) { if (!it) { FullScreenError( - R.drawable.twotone_wifi_find_24, - stringResource(R.string.internet_connection_needed) + Res.drawable.twotone_wifi_find_24, + stringResource(Res.string.internet_connection_needed) ) return@AnimatedContent } else { diff --git a/androidApp/src/play/java/io/github/chrisimx/scanbridge/zammadapi/models/VIPCreationResult.kt b/androidApp/src/play/java/io/github/chrisimx/scanbridge/zammadapi/models/VIPCreationResult.kt index bdb7e98d..b36b5096 100644 --- a/androidApp/src/play/java/io/github/chrisimx/scanbridge/zammadapi/models/VIPCreationResult.kt +++ b/androidApp/src/play/java/io/github/chrisimx/scanbridge/zammadapi/models/VIPCreationResult.kt @@ -1,21 +1,80 @@ package io.github.chrisimx.scanbridge.zammadapi.models import io.github.chrisimx.scanbridge.R +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.created_successfully +import scanbridge.composeui.generated.resources.google_proof_has_wrong_nonce +import scanbridge.composeui.generated.resources.google_proof_has_wrong_package_name +import scanbridge.composeui.generated.resources.google_proof_has_wrong_response_code +import scanbridge.composeui.generated.resources.google_proof_is_invalid +import scanbridge.composeui.generated.resources.google_proof_signature_is_invalid +import scanbridge.composeui.generated.resources.vip_creation_account_unable_to_be_created +import scanbridge.composeui.generated.resources.vip_creation_challenge_expired +import scanbridge.composeui.generated.resources.vip_creation_empty_email_or_name +import scanbridge.composeui.generated.resources.vip_creation_google_proof_is_from_the_future +import scanbridge.composeui.generated.resources.vip_creation_google_proof_is_not_recent_enough +import scanbridge.composeui.generated.resources.vip_creation_purchase_was_already_used +import scanbridge.composeui.generated.resources.vip_creation_token_is_invalid -enum class VIPCreationResult(val message: Int) { - Success(R.string.created_successfully), - TokenNotFound(R.string.vip_creation_token_is_invalid), - ChallengeExpired(R.string.vip_creation_challenge_expired), - InvalidSignature(R.string.google_proof_signature_is_invalid), - GoogleProofInvalid(R.string.google_proof_is_invalid), - WrongNonce(R.string.google_proof_has_wrong_nonce), - WrongPackageName(R.string.google_proof_has_wrong_package_name), - WrongGoogleResponseCode(R.string.google_proof_has_wrong_response_code), - PurchaseAlreadyUsed(R.string.vip_creation_purchase_was_already_used), - GoogleProofTooOld(R.string.vip_creation_google_proof_is_not_recent_enough), - GoogleProofFromTheFuture(R.string.vip_creation_google_proof_is_from_the_future), - AccountCouldntBeCreated(R.string.vip_creation_account_unable_to_be_created), - EmptyEmailOrName(R.string.vip_creation_empty_email_or_name) +enum class VIPCreationResult { + Success, + TokenNotFound, + ChallengeExpired, + InvalidSignature, + GoogleProofInvalid, + WrongNonce, + WrongPackageName, + WrongGoogleResponseCode, + PurchaseAlreadyUsed, + GoogleProofTooOld, + GoogleProofFromTheFuture, + AccountCouldntBeCreated, + EmptyEmailOrName, } data class JsonWrappedResult(val result: T) + +suspend fun VIPCreationResult.toLocalizedString(): String { + return when (this) { + VIPCreationResult.Success -> + getString(Res.string.created_successfully) + + VIPCreationResult.TokenNotFound -> + getString(Res.string.vip_creation_token_is_invalid) + + VIPCreationResult.ChallengeExpired -> + getString(Res.string.vip_creation_challenge_expired) + + VIPCreationResult.InvalidSignature -> + getString(Res.string.google_proof_signature_is_invalid) + + VIPCreationResult.GoogleProofInvalid -> + getString(Res.string.google_proof_is_invalid) + + VIPCreationResult.WrongNonce -> + getString(Res.string.google_proof_has_wrong_nonce) + + VIPCreationResult.WrongPackageName -> + getString(Res.string.google_proof_has_wrong_package_name) + + VIPCreationResult.WrongGoogleResponseCode -> + getString(Res.string.google_proof_has_wrong_response_code) + + VIPCreationResult.PurchaseAlreadyUsed -> + getString(Res.string.vip_creation_purchase_was_already_used) + + VIPCreationResult.GoogleProofTooOld -> + getString(Res.string.vip_creation_google_proof_is_not_recent_enough) + + VIPCreationResult.GoogleProofFromTheFuture -> + getString(Res.string.vip_creation_google_proof_is_from_the_future) + + VIPCreationResult.AccountCouldntBeCreated -> + getString(Res.string.vip_creation_account_unable_to_be_created) + + VIPCreationResult.EmptyEmailOrName -> + getString(Res.string.vip_creation_empty_email_or_name) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index c70a45c6..0201c86e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,9 @@ import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask +allprojects { + version = providers.gradleProperty("scanbridgeVersion").get() +} + plugins { alias(libs.plugins.kotlin.multiplatform).apply(false) alias(libs.plugins.compose.compiler).apply(false) diff --git a/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index f7881737..d99d95c0 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -7,6 +7,8 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.android.kmp.library) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.koin) } kotlin { @@ -27,6 +29,11 @@ kotlin { } } + iosArm64() + iosSimulatorArm64() + + applyDefaultHierarchyTemplate() + sourceSets { commonMain.dependencies { api(libs.compose.runtime) @@ -35,9 +42,20 @@ kotlin { api(libs.compose.resources) api(libs.compose.ui.tooling.preview) api(libs.compose.material3) + api(libs.compose.material.icons.extended) + api(libs.navigation.compose) + api(libs.koin.compose) + api(libs.koin.compose.viewmodel) + api(libs.koin.core) + api(libs.constraintlayout.compose.multiplatform) + api(libs.zoomable) + api(libs.filekit.dialogs.compose) + + api(libs.coil.compose) + api(libs.coil.fetcher.ktor) api(libs.anyscanCore) - api(project(":core")) + implementation(project(":core")) } commonTest.dependencies { @@ -51,6 +69,13 @@ kotlin { jvmMain.dependencies { implementation(compose.desktop.currentOs) } + + val mobileMain by creating { + dependsOn(commonMain.get()) + } + + androidMain.get().dependsOn(mobileMain) + iosMain.get().dependsOn(mobileMain) } targets diff --git a/composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.android.kt b/composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.android.kt new file mode 100644 index 00000000..422c01ea --- /dev/null +++ b/composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.android.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.clipboard + +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.toClipEntry + +actual fun String.toClipEntry(): ClipEntry { + return ClipData.newPlainText(this, this).toClipEntry() +} diff --git a/composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.android.kt b/composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.android.kt new file mode 100644 index 00000000..339f9d5d --- /dev/null +++ b/composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.android.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.platformhelper + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) { + BackHandler(enabled, onBack) +} diff --git a/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.apple.kt b/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.apple.kt new file mode 100644 index 00000000..e13dcb3f --- /dev/null +++ b/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.apple.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.clipboard + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry + +@OptIn(ExperimentalComposeUiApi::class) +actual fun String.toClipEntry(): ClipEntry { + return ClipEntry.withPlainText(this) +} diff --git a/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.apple.kt b/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.apple.kt new file mode 100644 index 00000000..9f92fc32 --- /dev/null +++ b/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.apple.kt @@ -0,0 +1,18 @@ +package io.github.chrisimx.scanbridge.platformhelper + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.backhandler.BackHandler +import androidx.navigationevent.NavigationEventHandler + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } +} diff --git a/composeUI/src/commonMain/composeResources/drawable/baseline_image_24.xml b/composeUI/src/commonMain/composeResources/drawable/baseline_image_24.xml new file mode 100644 index 00000000..a5e7e7ec --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/baseline_image_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/baseline_picture_as_pdf_24.xml b/composeUI/src/commonMain/composeResources/drawable/baseline_picture_as_pdf_24.xml new file mode 100644 index 00000000..1b64bb32 --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/baseline_picture_as_pdf_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/ic_rotate_right.xml b/composeUI/src/commonMain/composeResources/drawable/baseline_rotate_right_24.xml similarity index 81% rename from composeUI/src/commonMain/composeResources/drawable/ic_rotate_right.xml rename to composeUI/src/commonMain/composeResources/drawable/baseline_rotate_right_24.xml index 18106717..3638c3c2 100644 --- a/composeUI/src/commonMain/composeResources/drawable/ic_rotate_right.xml +++ b/composeUI/src/commonMain/composeResources/drawable/baseline_rotate_right_24.xml @@ -1,10 +1,13 @@ + android:width="24dp"> + - \ No newline at end of file + + diff --git a/composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml b/composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml new file mode 100644 index 00000000..68e5cb25 --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/github_mark.xml b/composeUI/src/commonMain/composeResources/drawable/github_mark.xml new file mode 100644 index 00000000..01af5bcf --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/github_mark.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/ic_cyclone.xml b/composeUI/src/commonMain/composeResources/drawable/ic_cyclone.xml deleted file mode 100644 index f1c45b5a..00000000 --- a/composeUI/src/commonMain/composeResources/drawable/ic_cyclone.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/drawable/ic_dark_mode.xml b/composeUI/src/commonMain/composeResources/drawable/ic_dark_mode.xml deleted file mode 100644 index 0ce2444a..00000000 --- a/composeUI/src/commonMain/composeResources/drawable/ic_dark_mode.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/drawable/ic_light_mode.xml b/composeUI/src/commonMain/composeResources/drawable/ic_light_mode.xml deleted file mode 100644 index b7331d3e..00000000 --- a/composeUI/src/commonMain/composeResources/drawable/ic_light_mode.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/composeUI/src/commonMain/composeResources/drawable/icon_about_dialog.png b/composeUI/src/commonMain/composeResources/drawable/icon_about_dialog.png new file mode 100644 index 00000000..696b4a24 Binary files /dev/null and b/composeUI/src/commonMain/composeResources/drawable/icon_about_dialog.png differ diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml new file mode 100644 index 00000000..e4c303ea --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml new file mode 100644 index 00000000..da3f216c --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml new file mode 100644 index 00000000..dd265468 --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_file_save_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_file_save_24.xml new file mode 100644 index 00000000..6aace552 --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/outline_file_save_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_pan_zoom_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_pan_zoom_24.xml new file mode 100644 index 00000000..bd87659a --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/outline_pan_zoom_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml new file mode 100644 index 00000000..5b99ae3f --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/round_print_36.xml b/composeUI/src/commonMain/composeResources/drawable/round_print_36.xml new file mode 100644 index 00000000..c6bfeea8 --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/round_print_36.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/rounded_adf_scanner_24.xml b/composeUI/src/commonMain/composeResources/drawable/rounded_adf_scanner_24.xml new file mode 100644 index 00000000..f63b777a --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_adf_scanner_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/rounded_content_copy_24.xml b/composeUI/src/commonMain/composeResources/drawable/rounded_content_copy_24.xml new file mode 100644 index 00000000..e8ad0c0f --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_content_copy_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/rounded_document_scanner_24.xml b/composeUI/src/commonMain/composeResources/drawable/rounded_document_scanner_24.xml new file mode 100644 index 00000000..2e6f290e --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_document_scanner_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml b/composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml new file mode 100644 index 00000000..5a98be4e --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml b/composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml new file mode 100644 index 00000000..d1b69eb5 --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeUI/src/commonMain/composeResources/drawable/twotone_wifi_find_24.xml b/composeUI/src/commonMain/composeResources/drawable/twotone_wifi_find_24.xml new file mode 100644 index 00000000..fe0ba91c --- /dev/null +++ b/composeUI/src/commonMain/composeResources/drawable/twotone_wifi_find_24.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index 4203b17a..0313f6b4 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -5,7 +5,7 @@ Es wurden keine Scanner gefunden. Bist du mit einem lokalen Netzwerk verbunden? Warnungssymbol Fehler - Fehler beim Abrufen der Eigenschaften des Scanners:\n\n%1$s + Fehler beim Abrufen der Eigenschaften des Scanners: Okay Die Eigenschaften des Scanners werden abgerufen Scannen @@ -135,4 +135,53 @@ Protokoll Initiale Scaneinstellungen Diese Einstellung definiert die bevorzugten anfänglichen Scaneinstellungen, die beim Starten einer neuen Scansitzung verwendet werden.\n\nIm Menü kannst du die gewünschten Optionen festlegen. Wenn deine Auswahl vom Scanner unterstützt wird, wird sie direkt verwendet. Andernfalls werden die Einstellungen so angepasst, dass sie vom Scanner unterstützt werden und deiner Auswahl möglichst nahekommen.\n\nWenn die Option „Speichern der zuletzt verwendeten Scaneinstellungen“ aktiviert ist, werden stattdessen die zuletzt verwendeten Scaneinstellungen verwendet, sofern vorhanden. + Support + Deine Lizenz wurde erfolgreich verifiziert. + Du scheinst keine Lizenz für ScanBridge zu besitzen. + Dein Account wurde erstellt. Du wirst in Kürze eine E-Mail erhalten, um dein Passwort festzulegen. Um die Accounterstellung abzuschließen, folge den dort genannten Schritten. Danach kannst du unseren Support durch eine E-Mail an support@fireamp.eu erreichen als auch über die Webseite https://support.fireamp.eu. Nutze dafür deine registrierte E-Mail. Beide Optionen kannst du hier aufrufen: + "Ich stimme der " + Fireamp-Datenschutzerklärung + ScanBridge wurde nicht auf Google Play gefunden. Dies ist ein interner Fehler. Bitte melde ihn an unseren Support mit einer E-Mail an support@fireamp.eu + Ein Fehler trat auf dem Google-Lizenzserver auf. + Der Google-Lizenzserver hat ein Bestätigen deiner Lizenz abgelehnt, da die maximale Häufigkeit der Anfragen überschritten wurde. + Die Google-Lizenzserver konnten nicht erreicht werden. + " zu." + Akzeptiere die Datenschutzerklärung, um dich zu registrieren. + Support über E-Mail + Support über Web + Registrierung bei Fireamp Support + Bitte verbinde dich mit dem Internet, um die Supportfunktionen verwenden zu können. + Willkommen bei Fireamp Support! + Zurück + Registrieren + Ungültiger Token + Account konnte nicht erstellt werden. Hast du eine gültige E-Mail und einen gültigen Namen eingetragen? Existiert bereits ein Account mit dieser E-Mail? + Die Lizenzbestätigung ist aus der Zukunft. + Die Lizenzbestätigung ist zu alt. + Dieser Kauf von ScanBridge wurde bereits verwendet um einen priorisierten Account zu erstellen. + Bitte gebe eine E-Mail und ein Passwort ein. + Die Lizenzbestätigung hat einen ungültigen Ergebniscode. + Die Lizenzbestätigung ist für eine andere App. + Die Lizenzbestätigung hat die falsche einmalige Nummer. + Die Lizenzbestätigung hat die falsche Form. + Die Lizenz konnte erst nach dem notwendigen Zeitintervall bestätigt werden. + Die Signatur der Google-Lizenzbestätigung ist ungültig. + Dein priorisierter Supportaccount wurde erstellt! + Die Google Play Services scheinen nicht installiert zu sein. Diese sind notwendig zum Bestätigen deines Kaufs von ScanBridge. + Die Lizenz wurde bestätigt, aber die Daten sind verloren gegangen. Bitte melde diesen Fehler an support@fireamp.eu. + Der zur Überprüfung der Lizenz verwendete öffentliche Schlüssel ist ungültig. Bitte melde dies an support@fireamp.eu. + ScanBridge fehlt die Berechtigung deine Lizenz zu überprüfen. + Die Google-Lizenzserver haben ungültige Daten geliefert. + Die Google-Lizenzserver haben einen unbekannten Antwortcode geliefert. Bitte melde dies an support@fireamp.eu. + ScanBridge scheint einen ungültigen Paketnamen zu haben. Die Lizenz kann nicht überprüft werden. Dies ist ein interner Fehler und nicht deine Schuld. Bitte melde dieses Problem unserem Support per E‑Mail an support@fireamp.eu. + Die Google-Lizenzserver konnten deinen Google-Account nicht bestimmen. Die Lizenz konnte deshalb nicht geprüft werden. + In diesem Bereich kannst du einen priorisierten Supportaccount erstellen. Registriere dich und erhalte eine E-Mail, um dein Passwort zu setzen. Danach kannst du deine registrierte E-Mail verwenden um support@fireamp.eu zu kontaktieren oder wahlweise dich auf https://support.fireamp.eu anmelden, um priorisierten Support zu erhalten. + Account erfolgreich erstellt + E-Mail + URL + Information zur Einstellung + iOS + Exportieren fehlgeschlagen: + Löschen fehlgeschlagen: + Das ausgwählte Exportmodul konnte nicht gefunden werden. \ 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 6da44d73..108c5beb 100644 --- a/composeUI/src/commonMain/composeResources/values-it/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-it/strings.xml @@ -4,7 +4,7 @@ Nessuno scanner disponibile. Sei connesso alla rete locale? Icona di avviso Errore - Si è verificato un errore durante il tentativo di recuperare le funzionalità dello scanner:\n\n%1$s + Si è verificato un errore durante il tentativo di recuperare le funzionalità dello scanner: OK Tentativo di recupero delle funzionalità dello scanner Scansiona @@ -133,4 +133,53 @@ Biglietto da visita Impostazioni di scansione iniziali Questa impostazione definisce le impostazioni di scansione iniziali preferite che vengono utilizzate quando si avvia una nuova sessione di scansione.\n\nNel menu puoi impostare le opzioni desiderate. Se la tua selezione è supportata dallo scanner, verrà utilizzata direttamente. In caso contrario, le impostazioni verranno adattate in modo da essere supportate dallo scanner e il più possibile vicine alla tua selezione.\n\nSe l'opzione „Ricorda le impostazioni di scansione“ è attivata, verranno invece utilizzate le impostazioni di scansione utilizzate più recentemente, se disponibili. + Support + Connettiti a Internet per poter utilizzare le funzioni di supporto. + Benvenuto nel supporto Fireamp! + Indietro + Registrarsi + La tua licenza è stata verificata con successo. + Sembra che tu non possieda una licenza per ScanBridge + ScanBridge non è stato trovato su Google Play. Si tratta di un errore interno. Ti preghiamo di segnalarlo al nostro supporto scrivendo a support@fireamp.eu. + Si è verificato un errore sul server di licenze di Google.“ + . + Accetta l’informativa sulla privacy per registrarti. + Support via e-mail + Support via web + Registrazione al supporto Fireamp + Il tuo account è stato creato. Riceverai a breve un’e-mail per impostare la tua password. Per completare la creazione dell’account, segui i passaggi indicati nella e-mail. Successivamente potrai contattare il nostro supporto sia via e-mail all’indirizzo support@fireamp.eu, sia tramite il sito web https://support.fireamp.eu , utilizzando la tua e-mail registrata. Entrambe le opzioni sono accessibili qui: + Registrandomi, accetto + " l’informativa sulla privacy di Fireamp" + La licenza può essere confermata solo dopo l’intervallo di tempo necessario. + La firma della conferma della licenza Google non è valida.“ + Il tuo account di supporto prioritario è stato creato! + In quest’area puoi creare un account di supporto prioritario. Registrati e riceverai un’e-mail per impostare la tua password. Successivamente potrai usare la tua e-mail registrata per contattare support@fireamp.eu oppure, in alternativa, accedere a https://support.fireamp.eu per ottenere supporto prioritario. + Account creato con successo + Token non valido + Il server delle licenze Google ha rifiutato la conferma della tua licenza, perché è stato superato il numero massimo di richieste. + Il server delle licenze Google non sono raggiungibili. + Sembra che ScanBridge abbia un nome pacchetto non valido. La licenza non può essere verificata. Si tratta di un errore interno e non è colpa tua. Ti preghiamo di segnalare questo problema al nostro supporto via e-mail all’indirizzo support@fireamp.eu. + I server delle licenze Google non sono riusciti a identificare il tuo account Google. Di conseguenza, la licenza non può essere verificata. + Sembra che i Google Play Services non siano installati. Sono necessari per confermare l’acquisto di ScanBridge. + Impossibile creare l’account. Hai inserito un’e-mail e un nome validi? Esiste già un account con questa e-mail? + La conferma della licenza proviene dal futuro. + La conferma della licenza è troppo vecchia. + Questo acquisto di ScanBridge è già stato utilizzato per creare un account prioritario. + Inserisci un’e-mail e una password. + La conferma della licenza ha un codice di risultato non valido. + La conferma della licenza è per un’altra app. + La conferma della licenza ha un numero una tantum (nonce) errato. + La conferma della licenza ha un formato errato. + La licenza è stata confermata, ma i dati sono andati persi. Ti preghiamo di segnalare questo errore a support@fireamp.eu. + La chiave pubblica utilizzata per verificare la licenza non è valida. Ti preghiamo di segnalare questo problema a support@fireamp.eu. + ScanBridge non ha l’autorizzazione per verificare la tua licenza. + I server delle licenze Google hanno fornito dati non validi. + I server delle licenze Google hanno restituito un codice di risposta sconosciuto. Ti preghiamo di segnalare questo problema a support@fireamp.eu + E-mail + URL + Informazioni dettagliate sull'impostazione + iOS + Esportazione non riuscita: + Eliminazione della scansione non riuscita: + Impossibile trovare il modulo di esportazione selezionato. \ 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 0841758f..ee5a994d 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -5,7 +5,7 @@ No scanners found. Are you connected to a local network? Warning icon Error - An error occurred while trying to retrieve scanner capabilities:\n\n%1$s + An error occurred while trying to retrieve scanner capabilities: Okay Trying to retrieve ScannerCapabilities Scan @@ -17,7 +17,7 @@ The job was completed successfully The job is still pending The pages are still processed - The job state can\'t be retrieved + The job state can't be retrieved Delete current page Swap with previous Swap with next @@ -48,7 +48,7 @@ Copy Error Scans unsaved - Do you really want to close the scanning interface? Any scans that you didn\'t export will be lost! + Do you really want to close the scanning interface? Any scans that you didn't export will be lost! Cancel Leave App icon @@ -65,6 +65,7 @@ Rotate right Rotating… Access custom scanner + URL Just connect URL (eSCL resource) Connect to a custom scanner @@ -75,7 +76,7 @@ Export as archive of JPEGs No further pages. %1$s Error while copying received image to file: %1$s - Couldn\'t decode received image. %1$s + Couldn't decode received image. %1$s Intent: Scan Region: Do you really want to delete the current page? This action can not be undone. @@ -94,7 +95,7 @@ Max. pages per PDF file As a smartphone has limited memory, it can becomes difficult to handle large PDFs. To prevent crashes, ScanBridge limits the maximum count pages stored in one PDF file when exporting scanned pages as PDF. If there are too much pages, multiple PDFs are created and stored into a ZIP archive. These PDFs can be merged afterwards, if needed.\n\nWarning: Increasing this too far can lead to crashes while exporting if a large number of pages are to be exported. Disable certificate checks - This option disables all HTTPS certificate validation. Any certificate will be accepted, including self-signed, expired, or otherwise invalid ones. Do not use this option on insecure or public networks, as it makes man-in-the-middle (MitM) attacks possible.\n\nIn most cases, scanners are used on private networks or over VPN connections, where concerns about authenticity are minimal. In such environments, disabling certificate checks is unlikely to pose a real security risk. And in case confidentiality or authenticity is a concern, even HTTPS won\'t help much, as eSCL lacks client authentication altogether. Anyone with access to the network can potentially retrieve scanned pages. + This option disables all HTTPS certificate validation. Any certificate will be accepted, including self-signed, expired, or otherwise invalid ones. Do not use this option on insecure or public networks, as it makes man-in-the-middle (MitM) attacks possible.\n\nIn most cases, scanners are used on private networks or over VPN connections, where concerns about authenticity are minimal. In such environments, disabling certificate checks is unlikely to pose a real security risk. And in case confidentiality or authenticity is a concern, even HTTPS won't help much, as eSCL lacks client authentication altogether. Anyone with access to the network can potentially retrieve scanned pages. Remember scan settings When enabled, your last used scan settings (input source, duplex, etc.) will be restored when you create a new scan session.\n\nWhen disabled, each session starts with default scan settings (highest resolution, largest possible scan area). Support development @@ -110,7 +111,7 @@ ScanBridge crashed :( "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" Loading previous session failed… %1$s - The scanner capabilities should be retrieved at this point, but they weren\'t. + The scanner capabilities should be retrieved at this point, but they weren't. mm Inches Scanned pages are received in the background. @@ -137,4 +138,53 @@ Protocol Initial scan settings This setting determines the initial scan settings used when starting a new scan session.\n\nIn the panel that opens, you can set the options you prefer. When your choice is supported by the scanner, it will be used. Else the closest supported settings will be used.\n\nThis setting can be overriden by the last used scan settings when the "Remeber scan settings" option is on. + Support + Your license was successfully verified. + You don't seem to own a license to this app + This app could not be found on Google Play and as such your license could not be verified. This is not your fault. Please report this issue to our support at support@fireamp.eu + An error has occurred on Google's licensing server. + Google's licensing server refused to confirm your license because the rate limit was exceeded + There was an error contacting Google's licensing server. + This app seems to have an invalid package name. The license cannot be checked. This is an internal error and not your fault. Please report this issue to our support using the e-mail support@fireamp.eu + Google's licensing server failed to determine your Google account. Your app license couldn't be checked because of this. + It seems like you haven't installed Google Play Services. These services are necessary to confirm that you've purchased our app. + Your license was confirmed, but there was an internal error and the licensing data could not be found. + The app's public key used for the signature verification of the licensing is invalid. This is not your fault. Please report this to support@fireamp.eu + The permission allowing to check your license is missing. Please report this issue to support@fireamp.eu + Google's licesning server responded with invalid data. + Google's licensing server responded with an unknown response code. Please report this to support@fireamp.eu. + To use the support feature, please connect your device to the internet + Welcome to Fireamp Support! + Back + Sign-up + Here you can create a prioritized support account. Sign-up and you will receive an e-mail shortly after to set your password. After that you can use the account e-mail to send your support inquiry to support@fireamp.eu or alternatively log-in under the website https://support.fireamp.eu and receive prioritized support. Both methods can directly be accessed here: + Created successfully + The token is invalid + Account could not be created. Are the entered email and name valid? Does an account with that e-mail maybe already exist? + Google proof is from the future. This should not be possible. + Google licensing proof is not recent enough. Is your internet exceptionally slow? + This purchase was already used to create a prioritized account. + Please enter an e-mail and a password. + Google licensing proof has wrong response code. + Google licesing proof is for the wrong package. + Google licensing proof has wrong nonce + Google licensing proof has wrong format + The challenge expired. Please try again. + Google licensing proof signature is invalid. + Your prioritized account was created! + Your account was created. You will receive an e-mail shortly that asks you to set a password. Follow the steps given there, to complete the account setup. After this, you will be able to reach our support either by e-mail to support@fireamp.eu or over the web at our website https://support.fireamp.eu. Both of these options can be opened here: + "By signing up, I accept the " + Fireamp Privacy Policy + . + Please accept the privacy policy to sign-up. + Contact support over e-mail + Open support website + Sign-up to Fireamp Support + Email + Detailed information regarding the setting + iOS + Error while exporting: + Error while deleting a scan: + The selected export module could not be found. + Desktop \ No newline at end of file diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/AppSettingsScreen.kt similarity index 82% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/AppSettingsScreen.kt index 389f869c..82e70d67 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/AppSettingsScreen.kt @@ -19,12 +19,6 @@ package io.github.chrisimx.scanbridge -import android.app.Activity.RESULT_OK -import android.content.Context -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -60,42 +54,50 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import io.github.chrisimx.scanbridge.appsettings.AppSettingsViewModel import io.github.chrisimx.scanbridge.buildinfo.ScanBridgeEdition -import io.github.chrisimx.scanbridge.datastore.* -import io.github.chrisimx.scanbridge.services.DebugLogService import io.github.chrisimx.scanbridge.uicomponents.TitledCard import io.github.chrisimx.scanbridge.uicomponents.dialog.SimpleTextDialog import io.github.chrisimx.scanbridge.uicomponents.settings.CheckboxSetting import io.github.chrisimx.scanbridge.uicomponents.settings.MoreInformationButton import io.github.chrisimx.scanbridge.uicomponents.settings.UIntSetting import io.github.chrisimx.scanbridge.uicomponents.settings.VersionComposable -import java.io.File import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.advanced +import scanbridge.composeui.generated.resources.clear_debug_log +import scanbridge.composeui.generated.resources.debug_log import scanbridge.composeui.generated.resources.debug_log_explanation +import scanbridge.composeui.generated.resources.disable_cert_checks import scanbridge.composeui.generated.resources.disable_cert_checks_desc +import scanbridge.composeui.generated.resources.donate +import scanbridge.composeui.generated.resources.export_debug_log +import scanbridge.composeui.generated.resources.favorite_24px +import scanbridge.composeui.generated.resources.github_mark import scanbridge.composeui.generated.resources.initial_scan_settings +import scanbridge.composeui.generated.resources.pdf_export_max_pages_per_pdf import scanbridge.composeui.generated.resources.pdf_export_setting_info import scanbridge.composeui.generated.resources.preferred_initial_scan_settings_setting_desc +import scanbridge.composeui.generated.resources.remember_scan_settings import scanbridge.composeui.generated.resources.remember_scan_settings_desc +import scanbridge.composeui.generated.resources.settings +import scanbridge.composeui.generated.resources.source_code +import scanbridge.composeui.generated.resources.timeout import scanbridge.composeui.generated.resources.timeout_info @Composable fun DisableCertChecksSetting(onInformationRequested: (StringResource) -> Unit, checked: Boolean, setChecked: (Boolean) -> Unit) { CheckboxSetting( - stringResource(R.string.disable_cert_checks), + stringResource(Res.string.disable_cert_checks), Res.string.disable_cert_checks_desc, checked, setChecked @@ -104,37 +106,8 @@ fun DisableCertChecksSetting(onInformationRequested: (StringResource) -> Unit, c } } -fun exportDebugLog(context: Context, debugLogService: DebugLogService, saveDebugLogLauncher: ActivityResultLauncher) { - debugLogService.flush() - - val debugDir = File(context.filesDir, "debug") - val debugFile = File(debugDir, "debug.txt") - - if (debugFile.exists()) { - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "text/plain" - intent.putExtra(Intent.EXTRA_TITLE, "debug_log.txt") // Suggested file name - - saveDebugLogLauncher.launch(intent) - } -} - @Composable fun DebugOptions(debugLog: Boolean, onInformationRequested: (StringResource) -> Unit, setWriteDebugLog: (Boolean) -> Unit) { - val context = LocalContext.current - val debugLogService: DebugLogService = koinInject() - - val debugFileSaveLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == RESULT_OK) { - val uri = result.data?.data - uri?.let { - debugLogService.saveToFile(it) - } - } - } ConstraintLayout( Modifier @@ -161,7 +134,7 @@ fun DebugOptions(debugLog: Boolean, onInformationRequested: (StringResource) -> } ) Text( - text = stringResource(R.string.debug_log), + text = stringResource(Res.string.debug_log), modifier = Modifier .constrainAs(content) { start.linkTo(checkbox.end, 12.dp) @@ -186,15 +159,15 @@ fun DebugOptions(debugLog: Boolean, onInformationRequested: (StringResource) -> } } OutlinedButton( - onClick = { debugLogService.clear() } + onClick = { } ) { - Text(stringResource(R.string.clear_debug_log)) + Text(stringResource(Res.string.clear_debug_log)) } OutlinedButton( - onClick = { exportDebugLog(context, debugLogService, debugFileSaveLauncher) } + onClick = { } ) { - Text(stringResource(R.string.export_debug_log)) + Text(stringResource(Res.string.export_debug_log)) } } @@ -307,16 +280,16 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { ) { Icon( modifier = Modifier.padding(end = 8.dp), - painter = painterResource(R.drawable.favorite_24px), + painter = painterResource(Res.drawable.favorite_24px), contentDescription = null ) - Text(stringResource(R.string.donate)) + Text(stringResource(Res.string.donate)) } } OutlinedIconButton(onClick = { uriHandler.openUri("https://github.com/Chrisimx/ScanBridge") }) { - Icon(painterResource(R.drawable.github_mark), contentDescription = stringResource(R.string.source_code)) + Icon(painterResource(Res.drawable.github_mark), contentDescription = stringResource(Res.string.source_code)) } } @@ -324,7 +297,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { FlowRow { TitledCard( - title = stringResource(R.string.settings) + title = stringResource(Res.string.settings) ) { DisableCertChecksSetting( setInformationRequested, @@ -333,7 +306,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { ) CheckboxSetting( - stringResource(R.string.remember_scan_settings), + stringResource(Res.string.remember_scan_settings), Res.string.remember_scan_settings_desc, rememberScanSettings, vm::setRememberScanSettings @@ -345,7 +318,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { UIntSetting( { vm.getScanningResponseTimeout() }, defaultScanningResponseTimeout, - stringResource(R.string.timeout), + stringResource(Res.string.timeout), Res.string.timeout_info, setInformationRequested, vm::setScanningResponseTimeout @@ -355,7 +328,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { UIntSetting( { vm.getPdfExportChunkSize() }, defaultPdfExportChunkSize, - stringResource(R.string.pdf_export_max_pages_per_pdf), + stringResource(Res.string.pdf_export_max_pages_per_pdf), Res.string.pdf_export_setting_info, setInformationRequested, vm::setPdfExportChunkSize, @@ -373,7 +346,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { } TitledCard( - title = stringResource(R.string.advanced) + title = stringResource(Res.string.advanced) ) { DebugOptions( writeDebugLogs, @@ -386,7 +359,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { val currentInfo = information if (currentInfo != null) { SimpleTextDialog( - org.jetbrains.compose.resources.stringResource(currentInfo), + stringResource(currentInfo), { information = null } ) } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt new file mode 100644 index 00000000..8451421b --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt @@ -0,0 +1,106 @@ +package io.github.chrisimx.scanbridge + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import io.github.chrisimx.scanbridge.clipboard.toClipEntry +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.copy +import scanbridge.composeui.generated.resources.crash +import scanbridge.composeui.generated.resources.crash_occurred +import scanbridge.composeui.generated.resources.outline_error_24 +import scanbridge.composeui.generated.resources.rounded_content_copy_24 + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun CrashDisplay( + error: String, + loggerFactory: ScanBridgeLoggerFactory = koinInject() +) { + val localClipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + val logger = loggerFactory.withTag("CrashDisplay") + + ScanBridgeTheme { + Scaffold( + floatingActionButton = { + FloatingActionButton({ + coroutineScope.launch { + try { + localClipboard.setClipEntry( + error.toClipEntry() + ) + } catch (e: Exception) { + logger.error { + "Error in CrashActivity while trying to copy crash to clipboard: $e" + } + } + } + }) { + Row(horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(Res.drawable.rounded_content_copy_24), + contentDescription = stringResource(Res.string.copy), + Modifier.padding(start = 10.dp, top = 10.dp, bottom = 10.dp, end = 5.dp) + ) + Text( + stringResource(Res.string.copy), + Modifier.padding(start = 5.dp, top = 10.dp, bottom = 10.dp, end = 10.dp) + ) + } + } + } + ) + { innerPadding -> + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painterResource(Res.drawable.outline_error_24), + stringResource(Res.string.crash), + Modifier.padding(20.dp).size(64.dp) + ) + + Column(modifier = Modifier.verticalScroll(scrollState)) { + Text( + stringResource(Res.string.crash_occurred), + modifier = Modifier.padding(bottom = 14.dp), + style = MaterialTheme.typography.bodySmallEmphasized + ) + Text( + error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + ) + } + } + } + } +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt new file mode 100644 index 00000000..6a13c657 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt @@ -0,0 +1,137 @@ +package io.github.chrisimx.scanbridge + +import androidx.compose.animation.core.snap +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.github.chrisimx.scanbridge.cropfeature.CropScreenViewModel +import io.github.chrisimx.scanbridge.cropfeature.CropState +import io.github.chrisimx.scanbridge.platformhelper.PlatformBackHandler +import io.github.chrisimx.scanbridge.uicomponents.CroppableAsyncImage +import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog +import io.github.chrisimx.scanbridge.util.clearAndNavigateTo +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.coroutines.launch +import me.saket.telephoto.zoomable.EnabledZoomGestures +import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.rememberZoomableState +import me.saket.telephoto.zoomable.zoomable +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.crop +import scanbridge.composeui.generated.resources.crop_button +import scanbridge.composeui.generated.resources.cropping +import scanbridge.composeui.generated.resources.desc_scanned_page +import scanbridge.composeui.generated.resources.outline_crop_24 + + +@OptIn(ExperimentalUuidApi::class) +@Composable +fun CropScreen( + scanId: Uuid, + returnRoute: BaseRoute, + navController: NavController, + viewModel: CropScreenViewModel = koinInject { + parametersOf(scanId) + } +) { + val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 5f)) + val coroutineScope = rememberCoroutineScope() + + val croppedImageFilePath by viewModel.croppedImageFilePath.collectAsState() + val cropState by viewModel.cropState.collectAsState() + + LaunchedEffect(cropState) { + if (cropState is CropState.Finished) { + navController.clearAndNavigateTo(returnRoute) + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + PlatformBackHandler { + navController.clearAndNavigateTo(returnRoute) + } + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + viewModel.finishCrop() + }, + modifier = Modifier.testTag("crop_finish"), + icon = { + Icon( + painter = painterResource(Res.drawable.outline_crop_24), + contentDescription = stringResource(Res.string.crop) + ) + }, + text = { Text(stringResource(Res.string.crop_button)) } + ) + }, + floatingActionButtonPosition = FabPosition.Center + + ) { innerPadding -> + if (cropState == CropState.Processing) { + LoadingDialog(text = Res.string.cropping) + } + + Box( + modifier = Modifier + .fillMaxSize() + .zoomable( + zoomableState, + gestures = EnabledZoomGestures.ZoomOnly + ), + contentAlignment = Alignment.CenterHorizontally.plus(Alignment.CenterVertically) + ) { + CroppableAsyncImage( + modifier = Modifier + .padding(innerPadding) + .padding(start = 40.dp, end = 40.dp, bottom = 100.dp, top = 15.dp), + imageModel = croppedImageFilePath, + contentDescription = stringResource(Res.string.desc_scanned_page), + additionalTouchAreaAround = 100.dp, + handleTouchRadius = 60.dp, + cropRectChanged = { composeRect -> + viewModel.setCropRect(composeRect.toScanBridgeRect()) + }, + onPan = { + coroutineScope.launch { + zoomableState.panBy(it, snap()) + } + } + ) + } + } +} + +fun Rect.toScanBridgeRect() = io.github.chrisimx.scanbridge.model.Rect( + left = left, + top = top, + right = right, + bottom = bottom +) diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeApp.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeApp.kt new file mode 100644 index 00000000..afce147b --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeApp.kt @@ -0,0 +1,82 @@ +/* + * 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 androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.savelastroute.LastRouteRepository +import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme +import io.github.chrisimx.scanbridge.uicomponents.StartupMessagesDisplay +import kotlinx.serialization.json.Json +import org.koin.compose.koinInject + +@Composable +fun ScanBridgeApp( + loggerFactory: ScanBridgeLoggerFactory = koinInject() +) { + ScanBridgeTheme { + val navController = rememberNavController() + var startDestination: Any? by remember { mutableStateOf(null) } + val logger = loggerFactory.withTag("ScanBridgeApp") + + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val typedRoute = currentBackStackEntry?.toTypedRoute() + + val lastRouteRepository = koinInject() + + LaunchedEffect(Unit) { + logger.debug { "Loading last route from shared preferences" } + val lastRoute = lastRouteRepository.getLastRoute() + if (lastRoute != null) { + try { + logger.debug { "Last route found: $lastRoute" } + val savedRoute = Json.decodeFromString(lastRoute) + startDestination = savedRoute + return@LaunchedEffect + } catch (e: Exception) { + logger.error { "Failed to deserialize saved route: $e" } + } + } + startDestination = StartUpScreenRoute + } + + if (startDestination != null) { + ScanBridgeNavHost(navController, startDestination!!) + } + + LaunchedEffect(typedRoute) { + typedRoute?.let { route -> + val json = Json.encodeToString(route) + logger.debug { "Route saved as: $json" } + + lastRouteRepository.setLastRoute(json) + } + } + + StartupMessagesDisplay() + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt similarity index 59% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index 88fb10e3..2c4793d4 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -21,80 +21,33 @@ package io.github.chrisimx.scanbridge import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository -import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.protocol.ScanningProtocolManager import io.github.chrisimx.scanbridge.uicomponents.FullScreenError -import io.github.chrisimx.scanbridge.uicomponents.TemporaryFileHandler -import io.github.chrisimx.scanbridge.util.doTempFilesExist +import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid 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 -sealed interface BaseRoute - -@Serializable -@SerialName("StartUpScreenRoute") -object StartUpScreenRoute : BaseRoute - -@Serializable -@SerialName("ScannerRoute") -@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") -data class CropImageRoute(val scanId: String, val returnRoute: String) : BaseRoute - -@Serializable -@SerialName("ErrorRoute") -data class ErrorRoute(val error: String) : BaseRoute - -fun NavBackStackEntry.toTypedRoute(): BaseRoute? { - Timber.d("Route changed to: ${destination.route}") - return when (destination.route) { - "StartUpScreenRoute" -> this.toRoute() - - "CropImageRoute/{scanId}/{pageIdx}/{returnRoute}" -> { - this.toRoute() - } - - "ScannerRoute/{scannerName}/{scannerHandleString}/{sessionID}?protocolId={protocolId}" -> { - this.toRoute() - } - - "ErrorRoute/{error}" -> { - this.toRoute() - } - - else -> null - } -} +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.outline_error_24 +@OptIn(ExperimentalUuidApi::class) @Composable -fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { - val context = LocalContext.current +fun ScanBridgeNavHost( + navController: NavHostController, + startDestination: Any, + loggerFactory: ScanBridgeLoggerFactory = koinInject() +) { val protocolManager = koinInject() val appSettingsRepo = koinInject() + val logger = loggerFactory.withTag("ScanBridgeNavHost") NavHost( modifier = Modifier.testTag("root_node"), @@ -105,21 +58,17 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { val errorRoute: ErrorRoute = backStackEntry.toRoute() val errorMessage = errorRoute.error - FullScreenError(R.drawable.outline_error_24, errorMessage, true) + FullScreenError(Res.drawable.outline_error_24, errorMessage, true) } composable { StartupScreen(navController) - - if (doTempFilesExist(context.filesDir)) { - TemporaryFileHandler() - } } composable { backStackEntry -> val scannerRoute: CropImageRoute = backStackEntry.toRoute() val returnRoute = try { Json.decodeFromString(scannerRoute.returnRoute) } catch (e: Exception) { - Timber.e(e, "Failed to decode returnRoute: ${scannerRoute.returnRoute}") + logger.error { "Failed to decode returnRoute: ${scannerRoute.returnRoute}: $e" } navController.navigate(StartUpScreenRoute) return@composable } @@ -135,10 +84,9 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { scannerRoute.protocolId, scannerRoute.scannerHandleString ) - Timber.tag("ScanBridgeNavHost") - .d( - "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}" - ) + logger.debug { + "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}" diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeRoutes.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeRoutes.kt new file mode 100644 index 00000000..70505b1c --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeRoutes.kt @@ -0,0 +1,55 @@ +package io.github.chrisimx.scanbridge + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.toRoute +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.Serializable + + +@Serializable +sealed interface BaseRoute + +@Serializable +@SerialName("StartUpScreenRoute") +object StartUpScreenRoute : BaseRoute + +@Serializable +@SerialName("ScannerRoute") +@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") +data class CropImageRoute(val scanId: String, val returnRoute: String) : BaseRoute + +@Serializable +@SerialName("ErrorRoute") +data class ErrorRoute(val error: String) : BaseRoute + +fun NavBackStackEntry.toTypedRoute(): BaseRoute? { + return when (destination.route) { + "StartUpScreenRoute" -> this.toRoute() + + "CropImageRoute/{scanId}/{pageIdx}/{returnRoute}" -> { + this.toRoute() + } + + "ScannerRoute/{scannerName}/{scannerHandleString}/{sessionID}?protocolId={protocolId}" -> { + this.toRoute() + } + + "ErrorRoute/{error}" -> { + this.toRoute() + } + + else -> null + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettings.kt similarity index 89% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettings.kt index 5e54d145..67ebf9b9 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -43,20 +43,30 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.LengthUnit import io.github.chrisimx.anyscan.ScanSettingParam import io.github.chrisimx.anyscan.ScannerConcept +import io.github.chrisimx.anyscan.equalsLength import io.github.chrisimx.scanbridge.model.UIInputSourceType 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.toLocalizedName -import io.github.chrisimx.scanbridge.util.toReadableString +import io.github.chrisimx.scanbridge.localizationhelper.toLocalizedName +import io.github.chrisimx.scanbridge.localizationhelper.toReadableString +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.custom +import scanbridge.composeui.generated.resources.height_in_unit +import scanbridge.composeui.generated.resources.inches +import scanbridge.composeui.generated.resources.input_source +import scanbridge.composeui.generated.resources.maximum_size +import scanbridge.composeui.generated.resources.millimeter_unit_abbreviation +import scanbridge.composeui.generated.resources.setting_duplex +import scanbridge.composeui.generated.resources.width_in_unit @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -68,8 +78,8 @@ fun ScanSettingsUI(modifier: Modifier, scanSettingsStateHolder: ScanSettingsComp val userUnitEnum by scanSettingsStateHolder.userLengthUnit.collectAsState(ScanSettingsLengthUnit.MILLIMETER) val userUnitString = when (userUnitEnum) { - ScanSettingsLengthUnit.INCH -> stringResource(R.string.inches) - ScanSettingsLengthUnit.MILLIMETER -> stringResource(R.string.millimeter_unit_abbreviation) + ScanSettingsLengthUnit.INCH -> stringResource(Res.string.inches) + ScanSettingsLengthUnit.MILLIMETER -> stringResource(Res.string.millimeter_unit_abbreviation) } val availableParameters by scanSettingsStateHolder.availableParameters.collectAsState() @@ -175,14 +185,14 @@ private fun RegionParameterDisplay( onClick = { scanSettingsStateHolder.selectMaxRegion() }, - label = { Text(stringResource(R.string.maximum_size)) }, + label = { Text(stringResource(Res.string.maximum_size)) }, selected = currentVmData.maximumSize && !currentVmData.customMenuEnabled ) InputChip( selected = currentVmData.customMenuEnabled, onClick = { scanSettingsStateHolder.setCustomMenuEnabled(true) }, - label = { Text(stringResource(R.string.custom)) } + label = { Text(stringResource(Res.string.custom)) } ) } AnimatedVisibility(currentVmData.customMenuEnabled) { @@ -192,7 +202,7 @@ private fun RegionParameterDisplay( modifier = Modifier .weight(1f) .padding(end = 10.dp), - stringResource(R.string.width_in_unit, userUnitString), + stringResource(Res.string.width_in_unit, userUnitString), { newText: String -> scanSettingsStateHolder.setCustomWidthTextFieldContent( newText @@ -205,7 +215,7 @@ private fun RegionParameterDisplay( modifier = Modifier .weight(1f) .padding(start = 10.dp), - stringResource(R.string.height_in_unit, userUnitString), + stringResource(Res.string.height_in_unit, userUnitString), { scanSettingsStateHolder.setCustomHeightTextFieldContent(it) }, heightValidationResult ) @@ -260,7 +270,7 @@ private fun InputSourceSelection( duplexCurrentlyAvailable: Boolean, duplexUsed: Boolean ) { - Text(stringResource(R.string.input_source)) + Text(stringResource(Res.string.input_source)) FlowRow( Modifier .fillMaxWidth() @@ -286,6 +296,6 @@ private fun InputSourceSelection( enabled = duplexCurrentlyAvailable, checked = duplexUsed, onCheckedChange = { scanSettingsStateHolder.setDuplex(it) } - ) { Text(stringResource(R.string.setting_duplex)) } + ) { Text(stringResource(Res.string.setting_duplex)) } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerBrowser.kt similarity index 73% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerBrowser.kt index 43abed53..3c6ea8dc 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -19,7 +19,6 @@ package io.github.chrisimx.scanbridge -import android.annotation.SuppressLint import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -35,27 +34,37 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import io.github.chrisimx.scanbridge.data.model.EditedCustomScanner import io.github.chrisimx.scanbridge.db.entities.CustomScanner import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.model.EditedCustomScanner 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 java.util.* import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -import org.koin.androidx.compose.koinViewModel +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.custom_scanner +import scanbridge.composeui.generated.resources.custom_scanner_deletion +import scanbridge.composeui.generated.resources.custom_scanner_deletion_confirmation +import scanbridge.composeui.generated.resources.discovered_scanners +import scanbridge.composeui.generated.resources.no_scanners_found +import scanbridge.composeui.generated.resources.saved_scanners +import scanbridge.composeui.generated.resources.twotone_wifi_find_24 +@OptIn(ExperimentalUuidApi::class) @Composable fun ScannerList( innerPadding: PaddingValues, @@ -89,7 +98,7 @@ fun ScannerList( if (customScanners.isNotEmpty() && discoveredScanners.isNotEmpty()) { item { Text( - stringResource(R.string.discovered_scanners), + stringResource(Res.string.discovered_scanners), modifier = Modifier.fillMaxWidth(1f).padding(start = 16.dp), style = MaterialTheme.typography.labelMedium ) @@ -123,7 +132,7 @@ fun ScannerList( if (customScanners.isNotEmpty() && discoveredScanners.isNotEmpty()) { item { Text( - stringResource(R.string.saved_scanners), + stringResource(Res.string.saved_scanners), modifier = Modifier.fillMaxWidth(1f).padding(start = 16.dp), style = MaterialTheme.typography.labelMedium ) @@ -147,6 +156,8 @@ fun ScannerBrowser( var deletionScheduledScanner: Uuid? by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + AnimatedContent( targetState = discoveredScanners.isNotEmpty() || customScanners.isNotEmpty(), label = "ScannerList" @@ -164,8 +175,8 @@ fun ScannerBrowser( ) } else { FullScreenError( - R.drawable.twotone_wifi_find_24, - stringResource(R.string.no_scanners_found) + Res.drawable.twotone_wifi_find_24, + stringResource(Res.string.no_scanners_found) ) } } @@ -173,8 +184,8 @@ fun ScannerBrowser( val deletionScheduledScannerImmutable = deletionScheduledScanner if (deletionScheduledScannerImmutable != null) { DeletionDialog( - R.string.custom_scanner_deletion, - R.string.custom_scanner_deletion_confirmation, + Res.string.custom_scanner_deletion, + Res.string.custom_scanner_deletion_confirmation, onDismiss = { deletionScheduledScanner = null }, onConfirmed = { scannerDiscoveryScreenViewModel.deleteScannerByUuid(deletionScheduledScannerImmutable) @@ -184,37 +195,40 @@ fun ScannerBrowser( } if (currentlyEditedScanner != null) { - val context = LocalContext.current - CustomScannerDialog( protocolsWithExampleHandle = protocolsForCustomScanners, onDismiss = { setEditedCustomDialog(null) }, onConnectClicked = { name, url, protocol, save, navigate -> - @SuppressLint("LocalContextGetResourceValueCall") - val name = name.ifEmpty { context.getString(R.string.custom_scanner) } - val sessionID = Uuid.random() - val uuid = when (currentlyEditedScanner) { - is EditedCustomScanner.EditingOld -> currentlyEditedScanner.scanner.uuid - EditedCustomScanner.New -> Uuid.random() - } - if (save) { - scannerDiscoveryScreenViewModel.addScanner( - CustomScanner(uuid, name, url, protocol) - ) - } - setEditedCustomDialog(null) - if (navigate) { - navController.navigate( - ScannerRoute( - name, - url.toString(), - protocol, - sessionID.toString() + coroutineScope.launch { + val defaultName = getString(Res.string.custom_scanner) + + val result = scannerDiscoveryScreenViewModel.onCustomScannerCreation( + name, + url, + protocol, + save, + defaultName, + currentlyEditedScanner + ) ?: return@launch + + val newCustomScanner = result.customScanner + val sessionID = result.sessionId + + setEditedCustomDialog(null) + if (navigate) { + navController.navigate( + ScannerRoute( + newCustomScanner.name, + newCustomScanner.url.toString(), + newCustomScanner.protocolIdentifier, + sessionID.toString() + ) ) - ) + } } }, - currentlyEditedScanner + currentlyEditedScanner, + scannerDiscoveryScreenViewModel::validateUrl ) } } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerIconImageLoader.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerIconImageLoader.kt new file mode 100644 index 00000000..00bd19f0 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerIconImageLoader.kt @@ -0,0 +1,30 @@ +package io.github.chrisimx.scanbridge + +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.network.ktor3.KtorNetworkFetcherFactory +import io.github.chrisimx.scanbridge.model.HttpClientConfig +import io.github.chrisimx.scanbridge.ports.HttpClientFactory +import io.ktor.client.HttpClient + +fun createScannerIconImageLoader(factory: HttpClientFactory, context: PlatformContext): ImageLoader { + val ktorClient: HttpClient = factory.create( + HttpClientConfig( + disableCertValidation = true, + debugLogging = false, + requestTimeoutInSeconds = 2u, + connectTimeoutInSeconds = 2u, + socketTimeoutInSeconds = 2u + ) + ) + return ImageLoader.Builder(context) + .components { + add( + KtorNetworkFetcherFactory( + httpClient = ktorClient + ) + ) + } + .build() +} + diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt similarity index 51% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt index da283dbe..e3909dbe 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -19,12 +19,6 @@ package io.github.chrisimx.scanbridge -import android.annotation.SuppressLint -import android.content.Context -import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -72,9 +66,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -83,33 +76,41 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp 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.model.toUIInputSourceType -import io.github.chrisimx.scanbridge.services.ScanJobEvent +import io.github.chrisimx.scanbridge.scanning.ScanJobEvent import io.github.chrisimx.scanbridge.uicomponents.ExportSettingsPopup import io.github.chrisimx.scanbridge.uicomponents.FullScreenError 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 java.io.File +import io.github.chrisimx.scanbridge.localizationhelper.toReadableString +import io.github.chrisimx.scanbridge.model.PositionAndHeight +import io.github.chrisimx.scanbridge.platformhelper.PlatformBackHandler +import io.github.chrisimx.scanbridge.scanning.FatalScanningScreenError +import io.github.chrisimx.scanbridge.scanning.FileSaveType +import io.github.chrisimx.scanbridge.scanning.ScanningScreenError +import io.github.chrisimx.scanbridge.scanning.ScanningScreenViewModel +import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog +import io.github.chrisimx.scanbridge.util.platformShareFile +import io.github.chrisimx.scanbridge.util.snackBarError +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.openFileSaver +import io.github.vinceglb.filekit.extension +import io.github.vinceglb.filekit.nameWithoutExtension +import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -117,22 +118,52 @@ import kotlinx.serialization.json.Json import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.zoomable -import org.koin.androidx.compose.koinViewModel +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.baseline_rotate_right_24 +import scanbridge.composeui.generated.resources.cancel_scan +import scanbridge.composeui.generated.resources.cancelling_scan +import scanbridge.composeui.generated.resources.crop +import scanbridge.composeui.generated.resources.delete_current_page +import scanbridge.composeui.generated.resources.desc_scanned_page +import scanbridge.composeui.generated.resources.export +import scanbridge.composeui.generated.resources.export_error +import scanbridge.composeui.generated.resources.exporting +import scanbridge.composeui.generated.resources.job_still_running +import scanbridge.composeui.generated.resources.no_scans_yet +import scanbridge.composeui.generated.resources.outline_crop_24 +import scanbridge.composeui.generated.resources.outline_file_save_24 +import scanbridge.composeui.generated.resources.outline_scan_24 +import scanbridge.composeui.generated.resources.page_deletion_confirmation +import scanbridge.composeui.generated.resources.page_x_of_y +import scanbridge.composeui.generated.resources.retrieving_page +import scanbridge.composeui.generated.resources.rotate_right +import scanbridge.composeui.generated.resources.rotating_page +import scanbridge.composeui.generated.resources.rounded_document_scanner_24 +import scanbridge.composeui.generated.resources.save_to_file +import scanbridge.composeui.generated.resources.scan +import scanbridge.composeui.generated.resources.scan_deletion_error +import scanbridge.composeui.generated.resources.scannercapabilities_retrieve_error +import scanbridge.composeui.generated.resources.selected_export_module_not_found +import scanbridge.composeui.generated.resources.settings +import scanbridge.composeui.generated.resources.swap_with_next_page +import scanbridge.composeui.generated.resources.swap_with_previous_page import scanbridge.composeui.generated.resources.trying_to_retrieve_scannercapabilities -import timber.log.Timber +import scanbridge.composeui.generated.resources.twotone_wifi_find_24 private const val TAG = "ScanningScreen" @Composable fun ScanningScreenBottomBar( scanningViewModel: ScanningScreenViewModel, - scope: CoroutineScope, - snackbarHostState: SnackbarHostState, - onSaveButtonPositionChanged: (Triple) -> Unit, - onExportPositionChange: (Triple) -> Unit -) { + setSaveButtonPositionAndHeight: (PositionAndHeight) -> Unit, + setExportButtonPositionAndHeight: (PositionAndHeight) -> Unit, + ) { BottomAppBar( actions = { IconButton( @@ -143,45 +174,47 @@ fun ScanningScreenBottomBar( ) } ) { - Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings)) + Icon(Icons.Filled.Settings, contentDescription = stringResource(Res.string.settings)) } - IconButton( - onClick = { - scanningViewModel.setShowSaveOptionsPopup(true) - }, - modifier = Modifier.onGloballyPositioned { - onSaveButtonPositionChanged( - Triple( - it.positionInWindow().x.toInt(), - it.positionInWindow().y.toInt(), - it.size.height + if (scanningViewModel.saveSupported) { + IconButton( + onClick = { + scanningViewModel.setShowSaveOptionsPopup(true) + }, + modifier = Modifier.onGloballyPositioned { + setSaveButtonPositionAndHeight( + PositionAndHeight( + it.positionInWindow().x.toInt(), + it.positionInWindow().y.toInt(), + it.size.height + ) ) + } + ) { + Icon( + painterResource(Res.drawable.outline_file_save_24), + contentDescription = stringResource(Res.string.save_to_file) ) } - ) { - Icon( - painterResource(R.drawable.outline_file_save_24), - contentDescription = stringResource(R.string.save_to_file) - ) } - IconButton( - onClick = { - scanningViewModel.setShowExportOptionsPopup(true) - }, - modifier = Modifier.onGloballyPositioned { - onExportPositionChange( - Triple( + if (scanningViewModel.shareSupported) { + IconButton( + onClick = { + scanningViewModel.setShowExportOptionsPopup(true) + }, + modifier = Modifier.onGloballyPositioned { + setExportButtonPositionAndHeight(PositionAndHeight( it.positionInWindow().x.toInt(), it.positionInWindow().y.toInt(), it.size.height - ) + )) + } + ) { + Icon( + Icons.Filled.Share, + contentDescription = stringResource(Res.string.export) ) } - ) { - Icon( - Icons.Filled.Share, - contentDescription = stringResource(R.string.export) - ) } }, floatingActionButton = { @@ -220,11 +253,11 @@ fun ScanningScreenBottomBar( contentColor = contentColor, icon = { Icon( - painter = painterResource(android.R.drawable.ic_menu_close_clear_cancel), + Icons.Default.Close, contentDescription = if (isCancelling) { - stringResource(R.string.cancelling_scan) + stringResource(Res.string.cancelling_scan) } else { - stringResource(R.string.cancel_scan) + stringResource(Res.string.cancel_scan) } ) }, @@ -237,9 +270,9 @@ fun ScanningScreenBottomBar( ) { targetCancelling -> Text( text = if (targetCancelling) { - stringResource(R.string.cancelling_scan) + stringResource(Res.string.cancelling_scan) } else { - stringResource(R.string.cancel_scan) + stringResource(Res.string.cancel_scan) } ) } @@ -249,19 +282,16 @@ fun ScanningScreenBottomBar( // Show scan button when not scanning ExtendedFloatingActionButton( onClick = { - scanningViewModel.scan( - scope, - snackbarHostState - ) + scanningViewModel.scan() }, modifier = Modifier.testTag("scanbtn"), icon = { Icon( - painter = painterResource(R.drawable.outline_scan_24), - contentDescription = stringResource(R.string.scan) + painter = painterResource(Res.drawable.outline_scan_24), + contentDescription = stringResource(Res.string.scan) ) }, - text = { Text(stringResource(R.string.scan)) } + text = { Text(stringResource(Res.string.scan)) } ) } } @@ -269,16 +299,7 @@ fun ScanningScreenBottomBar( ) } -fun saveFile(context: Context, sourceFile: File, destUri: Uri) { - context.contentResolver.openOutputStream(destUri)?.use { out -> - sourceFile.inputStream().use { input -> - input.copyTo(out) - } - } -} - -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable fun ScanningScreen( scannerName: String, @@ -300,20 +321,12 @@ fun ScanningScreen( ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val context = LocalContext.current - val isLoaded by remember { - derivedStateOf { - scanningViewModel.scanningScreenData.capabilities != null && - scanningViewModel.scanningScreenData.scanSettingsVM != null - } - } + val isReadyForScans by scanningViewModel.readyForScans.collectAsState() + val isRotating by scanningViewModel.isRotating.collectAsState() + val isExporting by scanningViewModel.isExporting.collectAsState() - val isError by remember { - derivedStateOf { - scanningViewModel.scanningScreenData.error != null - } - } + val fatalError by scanningViewModel.fatalError.collectAsState() val scannedPages by scanningViewModel.scannedPages.collectAsState() val currentPage by scanningViewModel.currentPage.collectAsState() @@ -321,6 +334,22 @@ fun ScanningScreen( val currentPageIdx by scanningViewModel.currentPageIdx.collectAsState() val scanJobRunning by scanningViewModel.isScanJobRunning.collectAsState() + val confirmPageDeleteDialogShown by scanningViewModel.confirmPageDeleteDialogShown.collectAsState() + val confirmLeaveDialogShown by scanningViewModel.confirmLeaveDialogShown.collectAsState() + + val availableExportModules by scanningViewModel.availableExportModules.collectAsState() + + var saveButtonPositionAndHeight by remember { + mutableStateOf(PositionAndHeight(0,0,0)) + } + var exportPositionAndHeight by remember { + mutableStateOf(PositionAndHeight(0,0,0)) + } + + val showExportPopup by scanningViewModel.showExportOptions.collectAsState() + val showSavePopup by scanningViewModel.showSaveOptions.collectAsState() + val scanSettingsMenuOpen by scanningViewModel.scanSettingsMenuOpen.collectAsState() + val pagerState = rememberPagerState( initialPage = currentPageIdx, pageCount = { @@ -328,13 +357,23 @@ fun ScanningScreen( } ) + val scanSettingsUIStateHolder by scanningViewModel.scanSettingsVM.collectAsState() + + val clipboard = LocalClipboard.current + LaunchedEffect(scanningViewModel.scanJobRepo.events) { scanningViewModel.scanJobRepo.events.collect { event -> when (event) { is ScanJobEvent.Completed -> scope.launch { pagerState.animateScrollToPage(scannedPages.size - 1) } // TODO: Localize this - is ScanJobEvent.Failed -> snackbarErrorRetrievingPage(event.error.unlocalizedMessage, scope, context, snackbarHostState) + is ScanJobEvent.Failed -> snackbarErrorRetrievingPage( + event.error.unlocalizedMessage, + scope, + snackbarHostState, + true, + clipboard + ) is ScanJobEvent.Started -> scope.launch { pagerState.animateScrollToPage(scannedPages.size) } } @@ -350,44 +389,32 @@ fun ScanningScreen( scanningViewModel.setPageIdx(pagerState.currentPage) } - if (!isLoaded) { - BackHandler { - navController.clearAndNavigateTo(StartUpScreenRoute) - } - - Scaffold { innerPadding -> - if (!isError) { - LaunchedEffect(Unit) { - scanningViewModel.retrieveScannerCapabilities() - } - - LoadingScreen( - loadingText = Res.string.trying_to_retrieve_scannercapabilities - ) - } + LaunchedEffect(Unit) { + scanningViewModel.errorStream.collect { error -> + val errorText = error.errorText() + val hasErrorText = errorText != null + val fullErrorText = error.errorPretext() + if (hasErrorText) "\n\n" + error.errorText() else "" - AnimatedVisibility( - isError, - enter = fadeIn(animationSpec = tween(1000)), - exit = fadeOut(animationSpec = tween(1000)) - ) { - val errorDescription = scanningViewModel.scanningScreenData.error - FullScreenError( - errorDescription?.icon ?: R.drawable.twotone_wifi_find_24, - stringResource( - errorDescription?.pretext ?: R.string.scannercapabilities_retrieve_error, - errorDescription?.text ?: "Error text not found" - ), - copyButton = true - ) - } + snackBarError( + fullErrorText, + scope, + snackbarHostState, + hasErrorText, + errorText, + clipboard + ) } - - return } - BackHandler { - scanningViewModel.setConfirmDialogShown(true) + if (confirmLeaveDialogShown) { + ConfirmCloseDialog( + onDismiss = { scanningViewModel.onLeaveDismissed() }, + onConfirmed = { + scanningViewModel.onLeaveConfirmed { + navController.clearAndNavigateTo(StartUpScreenRoute) + } + } + ) } Scaffold( @@ -439,31 +466,45 @@ fun ScanningScreen( } } }, - topBar = { }, + topBar = { + PlatformBackHandler { + scanningViewModel.leaveRequested() + } + }, bottomBar = { - ScanningScreenBottomBar( - scanningViewModel = scanningViewModel, - scope = scope, - snackbarHostState = snackbarHostState, - onExportPositionChange = { - scanningViewModel.setExportPopupPosition( - it.first, - it.second, - it.third - ) - }, - onSaveButtonPositionChanged = { - scanningViewModel.setSavePopupPosition( - it.first, - it.second, - it.third - ) - } - ) + if (isReadyForScans) { + ScanningScreenBottomBar( + scanningViewModel = scanningViewModel, + { saveButtonPositionAndHeight = it }, + { exportPositionAndHeight = it } + ) + } } ) { innerPadding -> + AnimatedVisibility( + fatalError != null, + enter = fadeIn(animationSpec = tween(1000)), + exit = fadeOut(animationSpec = tween(1000)) + ) { + val errorIcon = fatalError?.errorType?.errorIcon() ?: Res.drawable.twotone_wifi_find_24 + val errorPretext = fatalError?.errorType?.errorPretext() ?: stringResource(Res.string.scannercapabilities_retrieve_error) + val errorText = fatalError?.text ?: "Error text not found" + val fullScreenErrorText = errorPretext + "\n\n" + errorText + + FullScreenError( + errorIcon, + fullScreenErrorText, + copyButton = true + ) + } + + if (!isReadyForScans && fatalError == null) { + LoadingScreen( + loadingText = Res.string.trying_to_retrieve_scannercapabilities + ) + } - if (scanningViewModel.scanningScreenData.capabilities != null) { + if (isReadyForScans && fatalError == null) { ScanContent( innerPadding, scannerName, @@ -474,172 +515,109 @@ fun ScanningScreen( pagerState, scanJobRunning ) - } - if (scanningViewModel.scanningScreenData.scanSettingsMenuOpen) { - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp + if (scanSettingsMenuOpen) { + val configuration = LocalWindowInfo.current + val screenHeight = configuration.containerDpSize.height - ModalBottomSheet({ scanningViewModel.setScanSettingsMenuOpen(false) }) { - ScanSettingsUI( - Modifier.heightIn(max = screenHeight * 0.8f), - scanningViewModel.scanningScreenData.scanSettingsVM!! - ) + ModalBottomSheet({ scanningViewModel.setScanSettingsMenuOpen(false) }) { + ScanSettingsUI( + Modifier.heightIn(max = screenHeight * 0.8f), + scanSettingsUIStateHolder!! + ) + } } - } - val exportOptionsPopupPosition = - scanningViewModel.scanningScreenData.exportOptionsPopupPosition - var exportOptionsWidth by remember { mutableIntStateOf(0) } - val exportAlpha by animateFloatAsState( - targetValue = if (scanningViewModel.scanningScreenData.showExportOptions) 1f else 0f, - label = "alphaAnimationExportOptions", - animationSpec = tween(300) - ) - val saveOptionsPopupPosition = - scanningViewModel.scanningScreenData.saveOptionsPopupPosition - var saveOptionsWidth by remember { mutableIntStateOf(0) } - val saveOptionsAlpha by animateFloatAsState( - targetValue = if (scanningViewModel.scanningScreenData.showSaveOptions) 1f else 0f, - label = "alphaAnimationSaveOptions", - animationSpec = tween(300) - ) + val exportAlpha by animateFloatAsState( + targetValue = if (showExportPopup) 1f else 0f, + label = "alphaAnimationExportOptions", + animationSpec = tween(300) + ) + + val saveOptionsAlpha by animateFloatAsState( + targetValue = if (showSavePopup) 1f else 0f, + label = "alphaAnimationSaveOptions", + animationSpec = tween(300) + ) - val currentSourceFileToSave = scanningViewModel.scanningScreenData.sourceFileToSave + LaunchedEffect(Unit) { + scanningViewModel.exportQueue.collect { exportEvent -> - val saveFileLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> - uri?.let { uri -> - currentSourceFileToSave?.let { sourceFile -> - saveFile(context, sourceFile, uri) + when (exportEvent.saveType) { + FileSaveType.Share -> { + platformShareFile(exportEvent.exportedFile) + } + FileSaveType.Save -> { + val file = FileKit.openFileSaver( + suggestedName = exportEvent.exportedFile.nameWithoutExtension, + defaultExtension = exportEvent.exportedFile.extension, + ) + scanningViewModel.onSaveLocationSelected(exportEvent, file) + } } } } - Box( - modifier = Modifier - .fillMaxSize() - ) { - if (exportAlpha > 0) { - ExportSettingsPopup( - exportOptionsPopupPosition, - exportOptionsWidth, - exportAlpha, - onDismiss = { scanningViewModel.setShowExportOptionsPopup(false) }, - updateWidth = { exportOptionsWidth = it }, - onExportPdf = { - scanningViewModel.setShowExportOptionsPopup(false) - scanningViewModel.doPdfExport( - context, - { error -> - snackBarError( - error, - scope, - context, - snackbarHostState, - false - ) - } - ) - }, - onExportArchive = { - scanningViewModel.setShowExportOptionsPopup(false) - scanningViewModel.doZipExport( - context, - { error -> - snackBarError( - error, - scope, - context, - snackbarHostState, - false - ) - } - ) - } - ) + Box( + modifier = Modifier + .fillMaxSize() + ) { + if (exportAlpha > 0 && scanningViewModel.shareSupported) { + ExportSettingsPopup( + exportPositionAndHeight, + exportAlpha, + onDismiss = { scanningViewModel.setShowExportOptionsPopup(false) }, + availableExportModuleTypes = availableExportModules, + onExportModuleSelected = { selectedModule -> + scanningViewModel.exportAllPages( + selectedModule, + FileSaveType.Share + ) + } + ) + } + + if (saveOptionsAlpha > 0 && scanningViewModel.saveSupported) { + ExportSettingsPopup( + saveButtonPositionAndHeight, + saveOptionsAlpha, + onDismiss = { scanningViewModel.setShowSaveOptionsPopup(false) }, + availableExportModuleTypes = availableExportModules, + onExportModuleSelected = { selectedModule -> + scanningViewModel.exportAllPages( + selectedModule, + FileSaveType.Save + ) + } + ) + } } - if (saveOptionsAlpha > 0) { - ExportSettingsPopup( - saveOptionsPopupPosition, - saveOptionsWidth, - saveOptionsAlpha, - onDismiss = { scanningViewModel.setShowSaveOptionsPopup(false) }, - updateWidth = { exportOptionsWidth = it }, - onExportPdf = { - scanningViewModel.setShowSaveOptionsPopup(false) - scanningViewModel.doPdfExport( - context, - { error -> - snackBarError( - error, - scope, - context, - snackbarHostState, - false - ) - }, - saveFileLauncher - ) - }, - onExportArchive = { - scanningViewModel.setShowSaveOptionsPopup(false) - scanningViewModel.doZipExport( - context, - { error -> - snackBarError( - error, - scope, - context, - snackbarHostState, - false - ) - }, - saveFileLauncher - ) + if (confirmPageDeleteDialogShown) { + DeletionDialog( + Res.string.delete_current_page, + Res.string.page_deletion_confirmation, + onDismiss = scanningViewModel::onConfirmPageDeletionDismissed, + onConfirmed = { + currentPage?.let { + scanningViewModel.onConfirmPageDeletion(it) + } } ) } - } - - if (scanningViewModel.scanningScreenData.confirmPageDeleteDialogShown) { - DeletionDialog( - R.string.delete_current_page, - R.string.page_deletion_confirmation, - onDismiss = { scanningViewModel.setDeletePageDialogShown(false) }, - onConfirmed = { - Timber.d("Deleting page") - currentPage?.let { - scanningViewModel.removeScan(it) - } - - scanningViewModel.setDeletePageDialogShown(false) - } - ) - } - - if (scanningViewModel.scanningScreenData.progressStringResource != null) { - LoadingDialog(text = scanningViewModel.scanningScreenData.progressStringResource!!) - } + if (isRotating) { + LoadingDialog(text = Res.string.rotating_page) + } - if (scanningViewModel.scanningScreenData.confirmDialogShown) { - ConfirmCloseDialog( - onDismiss = { scanningViewModel.setConfirmDialogShown(false) }, - onConfirmed = { - scanningViewModel.deleteSession { - scanningViewModel.setConfirmDialogShown(false) - navController.clearAndNavigateTo(StartUpScreenRoute) - } - } - ) + if (isExporting) { + LoadingDialog(text = Res.string.exporting) + } } } } +@OptIn(ExperimentalUuidApi::class) @Composable fun ScanContent( innerPadding: PaddingValues, @@ -652,8 +630,6 @@ fun ScanContent( pagerState: PagerState, scanJobRunning: Boolean ) { - val context = LocalContext.current - Column( modifier = Modifier .fillMaxSize() @@ -665,7 +641,7 @@ fun ScanContent( Text(modifier = Modifier.padding(horizontal = 5.dp), text = scannerName, textAlign = TextAlign.Center) Text( stringResource( - R.string.page_x_of_y, + Res.string.page_x_of_y, pagerState.currentPage + 1, currentPages.size + if (scanJobRunning) 1 else 0 @@ -689,8 +665,8 @@ fun ScanContent( DownloadingPageFullscreen(innerPadding) } else { FullScreenError( - R.drawable.rounded_document_scanner_24, - stringResource(R.string.no_scans_yet) + Res.drawable.rounded_document_scanner_24, + stringResource(Res.string.no_scans_yet) ) } return@HorizontalPager @@ -707,7 +683,7 @@ fun ScanContent( val imagePath = currentPages.getOrNull(page)?.filePath AsyncImage( model = imagePath, - contentDescription = stringResource(R.string.desc_scanned_page), + contentDescription = stringResource(Res.string.desc_scanned_page), modifier = Modifier .testTag("scan_page") ) @@ -723,109 +699,153 @@ fun ScanContent( .padding(innerPadding), contentAlignment = Alignment.BottomCenter ) { - Box( - modifier = Modifier - .padding(10.dp) - .clip( - RoundedCornerShape(16.dp) + ToolbarScanContent(currentPages, pagerState, scanningViewModel, currentPage, coroutineScope, navController) + } + } +} + +@OptIn(ExperimentalUuidApi::class) +@Composable +private fun ToolbarScanContent( + currentPages: List, + pagerState: PagerState, + scanningViewModel: ScanningScreenViewModel, + currentPage: ScannedPage?, + coroutineScope: CoroutineScope, + navController: NavHostController? +) { + Box( + modifier = Modifier + .padding(10.dp) + .clip( + RoundedCornerShape(16.dp) + ) + .background(MaterialTheme.colorScheme.inverseOnSurface) + ) { + Row { + IconButton(onClick = { + if (currentPages.size <= pagerState.currentPage) { + return@IconButton + } + scanningViewModel.pageDeletionRequested() + }) { + Icon( + Icons.Outlined.Delete, + contentDescription = stringResource( + Res.string.delete_current_page ) - .background(MaterialTheme.colorScheme.inverseOnSurface) - ) { - Row { - IconButton(onClick = { - if (currentPages.size <= pagerState.currentPage) { - return@IconButton - } - scanningViewModel.setDeletePageDialogShown(true) - }) { - Icon( - Icons.Outlined.Delete, - contentDescription = stringResource( - R.string.delete_current_page - ) - ) - } - IconButton(onClick = { - val previousPage = currentPages.getOrNull(pagerState.currentPage - 1) - val currentPage = currentPage + ) + } + IconButton(onClick = { + val previousPage = currentPages.getOrNull(pagerState.currentPage - 1) - if (previousPage == null || currentPage == null) { - return@IconButton - } - scanningViewModel.swapTwoPages( - currentPage, - previousPage - ) - coroutineScope.launch { - pagerState.animateScrollToPage(pagerState.currentPage - 1) - } - }) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource( - R.string.swap_with_previous_page - ) - ) - } - IconButton(onClick = { - navController?.currentBackStackEntry?.toTypedRoute()?.let { currentRoute -> - currentPage?.let { currentPage -> - navController.clearAndNavigateTo( - CropImageRoute( - currentPage.scanId.toString(), - Json.encodeToString(currentRoute) - ) - ) - } - } - }) { - Icon( - painterResource(R.drawable.outline_crop_24), - contentDescription = stringResource( - R.string.crop + if (previousPage == null || currentPage == null) { + return@IconButton + } + scanningViewModel.swapTwoPages( + currentPage, + previousPage + ) + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + }) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource( + Res.string.swap_with_previous_page + ) + ) + } + IconButton(onClick = { + navController?.currentBackStackEntry?.toTypedRoute()?.let { currentRoute -> + currentPage?.let { currentPage -> + navController.clearAndNavigateTo( + CropImageRoute( + currentPage.scanId.toString(), + Json.encodeToString(currentRoute) ) ) } - IconButton(onClick = { - val nextPage = currentPages.getOrNull(pagerState.currentPage + 1) - val currentPage = currentPage + } + }) { + Icon( + painterResource(Res.drawable.outline_crop_24), + contentDescription = stringResource( + Res.string.crop + ) + ) + } + IconButton(onClick = { + val nextPage = currentPages.getOrNull(pagerState.currentPage + 1) - if (nextPage == null || currentPage == null) { - return@IconButton - } - scanningViewModel.swapTwoPages( - currentPage, - nextPage - ) - coroutineScope.launch { - pagerState.animateScrollToPage(pagerState.currentPage + 1) - } - }) { - Icon( - Icons.AutoMirrored.Outlined.ArrowForward, - contentDescription = stringResource( - R.string.swap_with_next_page - ) - ) - } - IconButton(onClick = { - currentPage?.let { - scanningViewModel.rotatePage(it.scanId) - } - }) { - Icon( - painterResource(R.drawable.baseline_rotate_right_24), - contentDescription = stringResource( - R.string.rotate_right - ) - ) - } + if (nextPage == null || currentPage == null) { + return@IconButton } + scanningViewModel.swapTwoPages( + currentPage, + nextPage + ) + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + }) { + Icon( + Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = stringResource( + Res.string.swap_with_next_page + ) + ) + } + IconButton(onClick = { + currentPage?.let { + scanningViewModel.rotatePage(it.scanId) + } + }) { + Icon( + painterResource(Res.drawable.baseline_rotate_right_24), + contentDescription = stringResource( + Res.string.rotate_right + ) + ) } } } } +@Composable +private fun FatalScanningScreenError.errorIcon(): DrawableResource { + return when (this) { + FatalScanningScreenError.ScannerCapsRetrieval -> Res.drawable.twotone_wifi_find_24 + } +} + +@Composable +private fun FatalScanningScreenError.errorPretext(): String { + return stringResource(when (this) { + FatalScanningScreenError.ScannerCapsRetrieval -> Res.string.scannercapabilities_retrieve_error + }) +} + +private suspend fun ScanningScreenError.errorPretext(): String { + return when (this) { + is ScanningScreenError.DeletionError -> getString(Res.string.scan_deletion_error) + is ScanningScreenError.ExportError -> getString(Res.string.export_error) + ScanningScreenError.ExportModuleNotFound -> getString(Res.string.selected_export_module_not_found) + ScanningScreenError.JobStillRunning -> getString(Res.string.job_still_running) + ScanningScreenError.NoPagesScannedYet -> getString(Res.string.no_scans_yet) + } +} + +private fun ScanningScreenError.errorText(): String? { + return when (this) { + is ScanningScreenError.DeletionError -> this.error.toString() + is ScanningScreenError.ExportError -> this.error.toString() + ScanningScreenError.ExportModuleNotFound, ScanningScreenError.JobStillRunning -> null + ScanningScreenError.NoPagesScannedYet -> null + } +} + @Composable private fun DownloadingPageFullscreen(innerPadding: PaddingValues) { Column( @@ -836,6 +856,6 @@ private fun DownloadingPageFullscreen(innerPadding: PaddingValues) { horizontalAlignment = Alignment.CenterHorizontally ) { CircularProgressIndicator() - Text(modifier = Modifier.padding(vertical = 15.dp), text = stringResource(R.string.retrieving_page)) + Text(modifier = Modifier.padding(vertical = 15.dp), text = stringResource(Res.string.retrieving_page)) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/StartupScreen.kt similarity index 78% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/StartupScreen.kt index d4fc666e..15a9e23c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/StartupScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/StartupScreen.kt @@ -20,7 +20,6 @@ package io.github.chrisimx.scanbridge import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Create @@ -41,11 +40,13 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector 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.EditedCustomScanner +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.custom_scanner_desc @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -55,27 +56,22 @@ fun ScannerDiscoveryTopBar(header: String) { ) } -data class StartupScreen( - val nameResource: Int, - val titleResource: Int, - val selectedIcon: ImageVector, - val unselectedIcon: ImageVector, - val fabActivated: Boolean, - val screenComposable: @Composable ( - innerPadding: PaddingValues, - navController: NavController, - showCustomDialog: EditedCustomScanner?, - setShowCustomDialog: (EditedCustomScanner?) -> Unit - ) -> Unit -) - -val INDEXED_TABS = STARTUP_TABS.withIndex() -val StartupScreenSaver = Saver, Int>(save = { it.index }, restore = { IndexedValue(it, STARTUP_TABS[it]) }) - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun StartupScreen(navController: NavController) { - var selectedScreen by rememberSaveable(stateSaver = StartupScreenSaver) { mutableStateOf(INDEXED_TABS.first()) } +fun StartupScreen( + navController: NavController, + startupTabsProvider: StartupTabsProvider = koinInject() +) { + val startupTabs = startupTabsProvider + .getStartupTabs() + + val indexedStartupTabs = startupTabs.withIndex() + + val startupScreenSaver = Saver, Int>(save = { it.index }, restore = { + IndexedValue(it, startupTabs[it]) + }) + + var selectedScreen by rememberSaveable(stateSaver = startupScreenSaver) { mutableStateOf(indexedStartupTabs.first()) } val unindexedSelectedScreen = selectedScreen.value var showCustomDialog: EditedCustomScanner? by remember { mutableStateOf(null) } @@ -85,7 +81,7 @@ fun StartupScreen(navController: NavController) { topBar = @Composable { ScannerDiscoveryTopBar(stringResource(unindexedSelectedScreen.titleResource)) }, bottomBar = @Composable { NavigationBar { - STARTUP_TABS.forEachIndexed { idx, screen -> + startupTabs.forEachIndexed { idx, screen -> NavigationBarItem( modifier = Modifier.testTag("bottombutton$idx"), icon = { @@ -111,7 +107,7 @@ fun StartupScreen(navController: NavController) { ) { Icon( Icons.Filled.Create, - contentDescription = stringResource(R.string.custom_scanner_desc) + contentDescription = stringResource(Res.string.custom_scanner_desc) ) } } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/StartupTabsProvider.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/StartupTabsProvider.kt new file mode 100644 index 00000000..3a82e4ea --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/StartupTabsProvider.kt @@ -0,0 +1,26 @@ +package io.github.chrisimx.scanbridge + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavController +import io.github.chrisimx.scanbridge.model.EditedCustomScanner +import org.jetbrains.compose.resources.StringResource + +data class StartupTabDefinition( + val nameResource: StringResource, + val titleResource: StringResource, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val fabActivated: Boolean, + val screenComposable: @Composable ( + innerPadding: PaddingValues, + navController: NavController, + showCustomDialog: EditedCustomScanner?, + setShowCustomDialog: (EditedCustomScanner?) -> Unit + ) -> Unit +) + +interface StartupTabsProvider { + fun getStartupTabs(): List +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.kt new file mode 100644 index 00000000..d46a3166 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.kt @@ -0,0 +1,5 @@ +package io.github.chrisimx.scanbridge.clipboard + +import androidx.compose.ui.platform.ClipEntry + +expect fun String.toClipEntry(): ClipEntry diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinComposeUICommonModule.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinComposeUICommonModule.kt new file mode 100644 index 00000000..1fa03faf --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinComposeUICommonModule.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.koin + +import io.github.chrisimx.scanbridge.createScannerIconImageLoader +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.plugin.module.dsl.create + +val KOIN_MODULE_COMPOSE_UI_COMMON = module { + single(named("scannerIconImageLoader")) { + create(::createScannerIconImageLoader) + } +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/JobStateLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/JobStateLocalization.kt new file mode 100644 index 00000000..56d5ddaf --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/JobStateLocalization.kt @@ -0,0 +1,20 @@ +package io.github.chrisimx.scanbridge.localizationhelper + +import io.github.chrisimx.esclkt.JobState +import org.jetbrains.compose.resources.getString +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.job_aborted_because_error +import scanbridge.composeui.generated.resources.job_canceled +import scanbridge.composeui.generated.resources.job_completed_successfully +import scanbridge.composeui.generated.resources.job_pages_still_processed +import scanbridge.composeui.generated.resources.job_state_cannot_be_retrieved +import scanbridge.composeui.generated.resources.job_still_pending + +suspend fun JobState?.toJobStateString(): String = when (this) { + JobState.Canceled -> getString(Res.string.job_canceled) + JobState.Aborted -> getString(Res.string.job_aborted_because_error) + JobState.Completed -> getString(Res.string.job_completed_successfully) + JobState.Pending -> getString(Res.string.job_still_pending) + JobState.Processing -> getString(Res.string.job_pages_still_processed) + null -> getString(Res.string.job_state_cannot_be_retrieved) +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScanSettingsChoiceLocalization.kt similarity index 97% rename from composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScanSettingsChoiceLocalization.kt index 8843a48b..c2b7f2c7 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScanSettingsChoiceLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util +package io.github.chrisimx.scanbridge.localizationhelper import androidx.compose.runtime.Composable import io.github.chrisimx.anyscan.ColorMode @@ -9,6 +9,7 @@ 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 kotlin.jvm.JvmName import org.jetbrains.compose.resources.stringResource import scanbridge.composeui.generated.resources.* import scanbridge.composeui.generated.resources.Res diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScannerConceptLocalization.kt similarity index 92% rename from composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScannerConceptLocalization.kt index 43c0cd74..90b45c78 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScannerConceptLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util +package io.github.chrisimx.scanbridge.localizationhelper import androidx.compose.runtime.Composable import io.github.chrisimx.anyscan.ScannerConcept diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/UIInputSourceTypeLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/UIInputSourceTypeLocalization.kt new file mode 100644 index 00000000..db009ca7 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/UIInputSourceTypeLocalization.kt @@ -0,0 +1,16 @@ +package io.github.chrisimx.scanbridge.localizationhelper + +import androidx.compose.runtime.Composable +import io.github.chrisimx.scanbridge.model.UIInputSourceType +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.adf +import scanbridge.composeui.generated.resources.platen + +@Composable +fun UIInputSourceType.toReadableString(): String = when (this) { + UIInputSourceType.PLATEN -> + stringResource(Res.string.platen) + + UIInputSourceType.ADF -> stringResource(Res.string.adf) +} diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.kt new file mode 100644 index 00000000..90446601 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.platformhelper + +import androidx.compose.runtime.Composable + +@Composable +expect fun PlatformBackHandler( + enabled: Boolean = true, + onBack: () -> Unit +) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt similarity index 97% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index 8f98f3ba..f193d183 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -1,6 +1,5 @@ package io.github.chrisimx.scanbridge.uicomponents -import android.annotation.SuppressLint import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.detectDragGestures @@ -28,9 +27,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -38,13 +35,15 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil3.ImageLoader +import coil3.compose.LocalPlatformContext import coil3.compose.SubcomposeAsyncImage import coil3.request.CachePolicy -import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme import kotlin.math.max import kotlin.math.min -import timber.log.Timber +import org.jetbrains.compose.resources.painterResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.icon_about_dialog val IntSizeSaver = Saver>( save = { listOf(it.width, it.height) }, @@ -301,13 +300,13 @@ fun CroppableAsyncImage( onPan: (Offset) -> Unit, imageLoader: ImageLoader? = null ) { - val context = LocalContext.current val density = LocalDensity.current val additionalTouchAreaAroundInPx = with(density) { additionalTouchAreaAround.toPx().toInt() } - val selectedImageLoader = imageLoader ?: ImageLoader.Builder(context) + val platformContext = LocalPlatformContext.current + val selectedImageLoader = imageLoader ?: ImageLoader.Builder(platformContext) .memoryCachePolicy(CachePolicy.DISABLED) .diskCachePolicy(CachePolicy.DISABLED) .build() @@ -362,11 +361,9 @@ fun CroppableAsyncImage( ) } -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Preview @Composable fun PreviewCropOverlay() { - Timber.plant(Timber.DebugTree()) Scaffold { innerPadding -> ScanBridgeTheme { @@ -380,7 +377,7 @@ fun PreviewCropOverlay() { mainContent = { Image( modifier = Modifier.fillMaxWidth(), - painter = painterResource(R.drawable.icon_about_dialog), + painter = painterResource(Res.drawable.icon_about_dialog), contentDescription = "Hallo" ) } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt new file mode 100644 index 00000000..f5801e23 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt @@ -0,0 +1,94 @@ +package io.github.chrisimx.scanbridge.uicomponents + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import io.github.chrisimx.scanbridge.export.ExportModuleType +import io.github.chrisimx.scanbridge.model.PositionAndHeight +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.baseline_image_24 +import scanbridge.composeui.generated.resources.baseline_picture_as_pdf_24 +import scanbridge.composeui.generated.resources.export_as_archive +import scanbridge.composeui.generated.resources.export_pdf + +@Composable +fun ExportSettingsPopup( + exportOptionsPopupPosition: PositionAndHeight?, + alpha: Float, + onDismiss: () -> Unit, + availableExportModuleTypes: List, + onExportModuleSelected: (ExportModuleType) -> Unit, +) { + var thisOptionsWidth by remember { mutableStateOf(0) } + val oneItemsWidth by derivedStateOf { thisOptionsWidth / availableExportModuleTypes.size } + + Popup( + alignment = Alignment.TopStart, + offset = IntOffset( + exportOptionsPopupPosition?.x?.minus((thisOptionsWidth - oneItemsWidth) / 2 ) ?: 0, + exportOptionsPopupPosition?.y?.minus(exportOptionsPopupPosition.height) ?: 0 + ), + onDismissRequest = { onDismiss() } + ) { + ElevatedCard( + modifier = Modifier + .onGloballyPositioned { coordinates -> + thisOptionsWidth = coordinates.size.width + } + .graphicsLayer { + this.alpha = alpha + }, + shape = RoundedCornerShape(30.dp) + ) { + Row { + availableExportModuleTypes.forEach { module -> + val moduleUIInfo = module.uiInformation() + + IconButton(onClick = { onExportModuleSelected(module) }) { + Icon( + painterResource(moduleUIInfo.icon), + contentDescription = moduleUIInfo.name + ) + } + } + } + } + } +} + +data class ExportModuleUIInformation( + val name: String, + val icon: DrawableResource, +) + +@Composable +fun ExportModuleType.uiInformation(): ExportModuleUIInformation { + return when (this) { + ExportModuleType.PDF -> ExportModuleUIInformation( + name = stringResource(Res.string.export_pdf), + icon = Res.drawable.baseline_picture_as_pdf_24 + ) + ExportModuleType.ZIP -> ExportModuleUIInformation( + name = stringResource(Res.string.export_as_archive), + icon = Res.drawable.baseline_image_24 + ) + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt similarity index 77% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index 26683e67..724d9720 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete @@ -41,25 +42,31 @@ 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 io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.core.qualifier.named -import timber.log.Timber +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.delete +import scanbridge.composeui.generated.resources.edit_custom_scanner +import scanbridge.composeui.generated.resources.outline_edit_24 +import scanbridge.composeui.generated.resources.print_symbol_desc +import scanbridge.composeui.generated.resources.round_print_36 @Composable -fun tintedPainterResource(id: Int, tint: Color): Painter { - val basePainter = painterResource(id) +fun tintedPainterResource(drawableResource: DrawableResource, tint: Color): Painter { + val basePainter = painterResource(drawableResource) return remember(basePainter, tint) { object : Painter() { @@ -78,6 +85,7 @@ fun tintedPainterResource(id: Int, tint: Color): Painter { } } +@OptIn(ExperimentalUuidApi::class) @Composable fun FoundScannerItem( scannerHandleString: String, @@ -86,8 +94,11 @@ fun FoundScannerItem( iconUrl: String?, navController: NavController, deleteScanner: (() -> Unit)? = null, - editScanner: (() -> Unit)? = null + editScanner: (() -> Unit)? = null, + loggerFactory: ScanBridgeLoggerFactory? = koinInject() ) { + val logger = loggerFactory?.withTag("FoundScannerItem") + ElevatedCard( modifier = Modifier .defaultMinSize(minHeight = 60.dp) @@ -112,7 +123,7 @@ fun FoundScannerItem( verticalAlignment = Alignment.CenterVertically ) { val tintedPlaceholder = tintedPainterResource( - R.drawable.round_print_36, + Res.drawable.round_print_36, MaterialTheme.colorScheme.surfaceTint ) @@ -123,10 +134,11 @@ fun FoundScannerItem( if (iconUrl != null) { AsyncImage( model = iconUrl, - contentDescription = stringResource(id = R.string.print_symbol_desc), + contentDescription = stringResource(Res.string.print_symbol_desc), imageLoader = imageLoader, modifier = Modifier .align(Alignment.CenterVertically) + .size(70.dp) .padding(17.dp), placeholder = tintedPlaceholder, error = tintedPlaceholder, @@ -141,10 +153,11 @@ fun FoundScannerItem( Icon( modifier = Modifier .align(Alignment.CenterVertically) + .size(70.dp) .padding(17.dp), - painter = painterResource(R.drawable.round_print_36), + painter = painterResource(Res.drawable.round_print_36), tint = MaterialTheme.colorScheme.surfaceTint, - contentDescription = stringResource(id = R.string.print_symbol_desc) + contentDescription = stringResource(Res.string.print_symbol_desc) ) } Column( @@ -170,26 +183,26 @@ 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 $scannerHandleString") + logger?.info { "Edit button clicked for custom scanner: $name at $scannerHandleString" } } ) { Icon( - painter = painterResource(R.drawable.outline_edit_24), + painter = painterResource(Res.drawable.outline_edit_24), tint = MaterialTheme.colorScheme.surfaceTint, - contentDescription = stringResource(id = R.string.edit_custom_scanner) + contentDescription = stringResource(Res.string.edit_custom_scanner) ) } IconButton( 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 $scannerHandleString") + logger?.info { "Delete button clicked for custom scanner: $name at $scannerHandleString" } } ) { Icon( imageVector = Icons.Rounded.Delete, tint = MaterialTheme.colorScheme.error, - contentDescription = stringResource(id = R.string.delete) + contentDescription = stringResource(Res.string.delete) ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt similarity index 71% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt index c4a62e52..fdfa67d6 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt @@ -19,8 +19,6 @@ package io.github.chrisimx.scanbridge.uicomponents -import android.content.ClipData -import android.content.ClipboardManager import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -36,24 +34,32 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import io.github.chrisimx.scanbridge.R +import io.github.chrisimx.scanbridge.clipboard.toClipEntry +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.copy +import scanbridge.composeui.generated.resources.warning_desc -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalComposeUiApi::class) @Composable -fun FullScreenError(errorIcon: Int, errorMessage: String, copyButton: Boolean = false, fontSize: TextUnit = 18.sp, title: String? = null) { - val context = LocalContext.current +fun FullScreenError(errorIcon: DrawableResource, errorMessage: String, copyButton: Boolean = false, fontSize: TextUnit = 18.sp, title: String? = null) { val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier @@ -65,7 +71,7 @@ fun FullScreenError(errorIcon: Int, errorMessage: String, copyButton: Boolean = ) { Icon( painter = painterResource(errorIcon), - contentDescription = stringResource(R.string.warning_desc), + contentDescription = stringResource(Res.string.warning_desc), modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary ) @@ -93,14 +99,17 @@ fun FullScreenError(errorIcon: Int, errorMessage: String, copyButton: Boolean = ) } + val localClipboard = LocalClipboard.current + if (copyButton) { OutlinedButton(onClick = { - val clipboardManager = context.getSystemService(ClipboardManager::class.java) - val clipData = - ClipData.newPlainText(context.getString(R.string.error), errorMessage) - clipboardManager.setPrimaryClip(clipData) + coroutineScope.launch { + localClipboard.setClipEntry( + errorMessage.toClipEntry() + ) + } }) { - Text(stringResource(R.string.copy)) + Text(stringResource(Res.string.copy)) } } } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt index fce7a540..625cbf34 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SelectionButtonRow.kt @@ -32,7 +32,7 @@ fun SelectionButtonRow(title: String, options: List, onSet: (T?) -> Unit, }, selected = option == value ) { - Text(name) + Text(name, maxLines = 1, softWrap = false) } } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/SizeBasedConditionalView.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SizeBasedConditionalView.kt similarity index 100% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/SizeBasedConditionalView.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/SizeBasedConditionalView.kt diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/StartupMessagesDisplay.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/StartupMessagesDisplay.kt new file mode 100644 index 00000000..5ecb9264 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/StartupMessagesDisplay.kt @@ -0,0 +1,78 @@ +package io.github.chrisimx.scanbridge.uicomponents + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository +import io.github.chrisimx.scanbridge.startupmessages.StartupMessage +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.okay_text +import scanbridge.composeui.generated.resources.thank_message_play_one +import scanbridge.composeui.generated.resources.thank_message_play_one_title + +@Composable +fun StartupMessagesDisplay() { + val coroutineScope = rememberCoroutineScope() + val shownMessagesRepository = koinInject() + + val markThanksMessageAsRead = { + coroutineScope.launch { + shownMessagesRepository.setShown(StartupMessage.THANKS_FOR_PURCHASE, true) + } + } + + val thanksForPurchaseAlreadyShown by shownMessagesRepository + .getWasShownFlow(StartupMessage.THANKS_FOR_PURCHASE) + .collectAsState(true) + + if (!thanksForPurchaseAlreadyShown) { + AlertDialog( + { + markThanksMessageAsRead() + }, + { + TextButton( + onClick = { + markThanksMessageAsRead() + } + ) { + Text(stringResource(Res.string.okay_text)) + } + }, + icon = { + Icon( + Icons.Rounded.Favorite, + "Thank you", + Modifier.size(60.dp), + tint = Color.Red + ) + }, + title = { + Text( + stringResource(Res.string.thank_message_play_one_title), + textAlign = TextAlign.Center + ) + }, + text = { + Text( + stringResource(Res.string.thank_message_play_one) + ) + } + ) + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/TitledCard.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/TitledCard.kt similarity index 100% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/TitledCard.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/TitledCard.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/ConfirmCloseDialog.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/ConfirmCloseDialog.kt similarity index 73% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/ConfirmCloseDialog.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/ConfirmCloseDialog.kt index 71f5dd41..04f7cf59 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/ConfirmCloseDialog.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/ConfirmCloseDialog.kt @@ -29,9 +29,14 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import io.github.chrisimx.scanbridge.R +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.cancel_text +import scanbridge.composeui.generated.resources.leave_text +import scanbridge.composeui.generated.resources.scans_will_be_lost_text +import scanbridge.composeui.generated.resources.scans_will_be_lost_title +import scanbridge.composeui.generated.resources.warning_desc @Composable fun ConfirmCloseDialog(onDismiss: () -> Unit, onConfirmed: () -> Unit) { @@ -39,15 +44,15 @@ fun ConfirmCloseDialog(onDismiss: () -> Unit, onConfirmed: () -> Unit) { icon = { Icon( imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(R.string.warning_desc), + contentDescription = stringResource(Res.string.warning_desc), modifier = Modifier.size(48.dp) ) }, title = { - Text(text = stringResource(R.string.scans_will_be_lost_title)) + Text(text = stringResource(Res.string.scans_will_be_lost_title)) }, text = { - Text(text = stringResource(R.string.scans_will_be_lost_text)) + Text(text = stringResource(Res.string.scans_will_be_lost_text)) }, onDismissRequest = { onDismiss() @@ -58,7 +63,7 @@ fun ConfirmCloseDialog(onDismiss: () -> Unit, onConfirmed: () -> Unit) { onDismiss() } ) { - Text(stringResource(R.string.cancel_text)) + Text(stringResource(Res.string.cancel_text)) } }, confirmButton = { @@ -68,7 +73,7 @@ fun ConfirmCloseDialog(onDismiss: () -> Unit, onConfirmed: () -> Unit) { onConfirmed() } ) { - Text(stringResource(R.string.leave_text)) + Text(stringResource(Res.string.leave_text)) } } ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt similarity index 69% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index a4089256..e579a594 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt @@ -1,6 +1,5 @@ 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 @@ -20,31 +19,46 @@ 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.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.model.EditedCustomScanner +import io.github.chrisimx.scanbridge.model.UrlValidationResult 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.connect +import scanbridge.composeui.generated.resources.connect_and_save +import scanbridge.composeui.generated.resources.custom_scanner_url +import scanbridge.composeui.generated.resources.edit_custom_scanner +import scanbridge.composeui.generated.resources.error_state_please_enter_an_url +import scanbridge.composeui.generated.resources.invalid_url +import scanbridge.composeui.generated.resources.name +import scanbridge.composeui.generated.resources.new_custom_scanner_dialog_title +import scanbridge.composeui.generated.resources.save +import scanbridge.composeui.generated.resources.scanner_name_placeholder import scanbridge.composeui.generated.resources.scanning_protocol +@Composable +fun UrlValidationResult.toLocalizedString(): String = when (this) { + UrlValidationResult.Empty -> stringResource(Res.string.error_state_please_enter_an_url) + UrlValidationResult.InvalidUrl -> stringResource(Res.string.invalid_url) + is UrlValidationResult.NoError -> "" +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CustomScannerDialog( protocolsWithExampleHandle: List, onDismiss: () -> Unit, - onConnectClicked: (name: String, url: Url, protocol: String, save: Boolean, navigate: Boolean) -> Unit, - editingType: EditedCustomScanner + onConnectClicked: (name: String, url: String, protocol: String, save: Boolean, navigate: Boolean) -> Unit, + editingType: EditedCustomScanner, + validateUrl: (String) -> UrlValidationResult ) { - var urlErrorState: String? by remember { mutableStateOf(null) } val initializeWith = when (editingType) { is EditedCustomScanner.EditingOld -> editingType.scanner EditedCustomScanner.New -> null @@ -57,29 +71,14 @@ fun CustomScannerDialog( } var nameText: String by remember { mutableStateOf(initializeWith?.name ?: "") } + var urlValidationResult: UrlValidationResult by remember { mutableStateOf(validateUrl(urlText)) } + 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) - return null - } - - try { - return Url(urlText) - } catch (_: Exception) { - urlErrorState = context.getString(R.string.invalid_url) - return null - } - } - Dialog( onDismissRequest = { onDismiss() } ) { @@ -95,9 +94,9 @@ fun CustomScannerDialog( horizontalAlignment = Alignment.CenterHorizontally ) { val title = if (isNewScanner) { - R.string.new_custom_scanner_dialog_title + Res.string.new_custom_scanner_dialog_title } else { - R.string.edit_custom_scanner + Res.string.edit_custom_scanner } Text( @@ -111,26 +110,24 @@ fun CustomScannerDialog( onValueChange = { nameText = it }, - label = { Text(stringResource(R.string.name)) }, - placeholder = { Text(stringResource(R.string.scanner_name_placeholder)) } + label = { Text(stringResource(Res.string.name)) }, + placeholder = { Text(stringResource(Res.string.scanner_name_placeholder)) } ) OutlinedTextField( modifier = Modifier.testTag("url_input").padding(top = 16.dp), value = urlText, onValueChange = { - urlErrorState = null + urlValidationResult = validateUrl(it) urlText = it }, - label = { Text(stringResource(R.string.custom_scanner_url)) }, + label = { Text(stringResource(Res.string.custom_scanner_url)) }, placeholder = { Text(selectedProtocol.exampleScannerIdentifierString) }, supportingText = { - urlErrorState?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelMedium - ) - } + Text( + urlValidationResult.toLocalizedString(), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium + ) } ) @@ -145,10 +142,9 @@ fun CustomScannerDialog( if (isNewScanner) { Button( onClick = { - val url = validateUrl() ?: return@Button onConnectClicked( nameText, - url, + urlText, selectedProtocol.protocolIdentifier, true, true @@ -156,14 +152,13 @@ fun CustomScannerDialog( }, modifier = Modifier.padding(top = 16.dp) ) { - Text(stringResource(R.string.connect_and_save)) + Text(stringResource(Res.string.connect_and_save)) } Button( onClick = { - val url = validateUrl() ?: return@Button onConnectClicked( nameText, - url, + urlText, selectedProtocol.protocolIdentifier, false, true @@ -171,15 +166,14 @@ fun CustomScannerDialog( }, modifier = Modifier.padding(top = 8.dp).testTag("justconnect") ) { - Text(stringResource(R.string.connect)) + Text(stringResource(Res.string.connect)) } } else { Button( onClick = { - val url = validateUrl() ?: return@Button onConnectClicked( nameText, - url, + urlText, selectedProtocol.protocolIdentifier, true, false @@ -187,7 +181,7 @@ fun CustomScannerDialog( }, modifier = Modifier.padding(top = 16.dp).testTag("editcustomscanner") ) { - Text(stringResource(R.string.save)) + Text(stringResource(Res.string.save)) } } } @@ -195,7 +189,8 @@ fun CustomScannerDialog( } } -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + +@Suppress("UnusedMaterial3ScaffoldPaddingParameter") @Preview @Composable fun PreviewCustomScannerDialog() { @@ -207,8 +202,10 @@ fun PreviewCustomScannerDialog() { ), {}, { _, _, _, _, _ -> Unit }, - EditedCustomScanner.New - + EditedCustomScanner.New, + { urlString -> + UrlValidationResult.NoError(Url(urlString)) + } ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/DeleteDialog.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/DeleteDialog.kt similarity index 84% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/DeleteDialog.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/DeleteDialog.kt index 9447d729..ffb70d8c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/DeleteDialog.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/DeleteDialog.kt @@ -19,7 +19,6 @@ package io.github.chrisimx.scanbridge.uicomponents.dialog -import androidx.annotation.StringRes import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete @@ -31,16 +30,17 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import io.github.chrisimx.scanbridge.R +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.cancel_text +import scanbridge.composeui.generated.resources.delete @Composable fun DeletionDialog( - @StringRes - titleRes: Int, - @StringRes - confirmationQuestionRes: Int, + titleRes: StringResource, + confirmationQuestionRes: StringResource, onDismiss: () -> Unit, onConfirmed: () -> Unit ) { @@ -68,7 +68,7 @@ fun DeletionDialog( onDismiss() } ) { - Text(stringResource(R.string.cancel_text)) + Text(stringResource(Res.string.cancel_text)) } }, confirmButton = { @@ -80,7 +80,7 @@ fun DeletionDialog( onConfirmed() } ) { - Text(stringResource(R.string.delete)) + Text(stringResource(Res.string.delete)) } } ) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/LoadingDialog.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/LoadingDialog.kt similarity index 93% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/LoadingDialog.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/LoadingDialog.kt index f7d5f726..c5d8fa1d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/LoadingDialog.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/LoadingDialog.kt @@ -32,12 +32,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource @Composable -fun LoadingDialog(text: Int) { +fun LoadingDialog(text: StringResource) { Dialog( onDismissRequest = { } ) { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/SimpleTextDialog.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/SimpleTextDialog.kt similarity index 100% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/SimpleTextDialog.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/SimpleTextDialog.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt similarity index 100% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/MoreInformationButton.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/MoreInformationButton.kt similarity index 83% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/MoreInformationButton.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/MoreInformationButton.kt index 7a0abe1e..7f258931 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/MoreInformationButton.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/MoreInformationButton.kt @@ -24,8 +24,9 @@ import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import io.github.chrisimx.scanbridge.R +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.setting_more_information @Composable fun MoreInformationButton(clicked: () -> Unit) { @@ -34,7 +35,7 @@ fun MoreInformationButton(clicked: () -> Unit) { }) { Icon( Icons.Outlined.Info, - contentDescription = stringResource(R.string.auto_cleanup_info_desc) + contentDescription = stringResource(Res.string.setting_more_information) ) } } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt similarity index 91% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt index 1949d763..7ed01ccc 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt @@ -36,10 +36,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource -import timber.log.Timber +import org.koin.compose.koinInject @Composable fun UIntSettingBase( @@ -50,9 +52,11 @@ fun UIntSettingBase( onHelpRequested: (StringResource) -> Unit, changeSetting: (UInt) -> Unit, min: UInt, - max: UInt + max: UInt, + loggerFactory: ScanBridgeLoggerFactory = koinInject() ) { var timeoutValue by remember { mutableStateOf(placeholder) } + val logger = loggerFactory.withTag("UIntSetting") LaunchedEffect(Unit) { val initial = withContext(Dispatchers.IO) { @@ -78,10 +82,10 @@ fun UIntSettingBase( if (value in min..max) { changeSetting(value) } else { - Timber.w("Value out of range: $value. Ignored") + logger.warn { "Value out of range: $value. Ignored" } } } catch (_: NumberFormatException) { - Timber.w("Invalid setting value. Ignored") + logger.warn { "Invalid setting value. Ignored" } } } }, diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt similarity index 73% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt index 3ea16f00..18489a75 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt @@ -30,23 +30,32 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.buildinfo.ScanBridgeEdition import io.github.chrisimx.scanbridge.theme.Poppins import io.github.chrisimx.scanbridge.theme.gradientBrush +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.app_icon_desc +import scanbridge.composeui.generated.resources.app_name +import scanbridge.composeui.generated.resources.debug_build +import scanbridge.composeui.generated.resources.desktop_edition +import scanbridge.composeui.generated.resources.f_droid +import scanbridge.composeui.generated.resources.google_play +import scanbridge.composeui.generated.resources.icon_about_dialog +import scanbridge.composeui.generated.resources.ios @Composable fun ScanBridgeEdition.toLocalizedString(): String = when (this) { - ScanBridgeEdition.FDROID -> stringResource(R.string.f_droid) - ScanBridgeEdition.PLAYSTORE -> stringResource(R.string.google_play) - ScanBridgeEdition.IOS -> stringResource(R.string.ios) + ScanBridgeEdition.FDROID -> stringResource(Res.string.f_droid) + ScanBridgeEdition.PLAYSTORE -> stringResource(Res.string.google_play) + ScanBridgeEdition.IOS -> stringResource(Res.string.ios) + ScanBridgeEdition.DESKTOP -> stringResource(Res.string.desktop_edition) } @Composable @@ -55,12 +64,12 @@ fun VersionComposable(versionName: String, versionCode: Int, gitCommitHash: Stri modifier = Modifier .size(200.dp) .padding(16.dp), - painter = painterResource(R.drawable.icon_about_dialog), - contentDescription = stringResource(id = R.string.app_icon_desc) + painter = painterResource(Res.drawable.icon_about_dialog), + contentDescription = stringResource(Res.string.app_icon_desc) ) Text( - stringResource(R.string.app_name), + stringResource(Res.string.app_name), modifier = Modifier.padding(PaddingValues(4.dp)), fontFamily = Poppins(), fontSize = 24.sp, @@ -81,7 +90,7 @@ fun VersionComposable(versionName: String, versionCode: Int, gitCommitHash: Stri editionNotice.add(edition.toLocalizedString()) if (debugBuild) { - editionNotice.add(stringResource(R.string.debug_build)) + editionNotice.add(stringResource(Res.string.debug_build)) } Text( diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt similarity index 100% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.kt new file mode 100644 index 00000000..e32e3ea8 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.kt @@ -0,0 +1,5 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.vinceglb.filekit.PlatformFile + +expect suspend fun platformShareFile(file: PlatformFile) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SnackbarUtils.kt similarity index 75% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SnackbarUtils.kt index 2ffa085f..b800c8da 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SnackbarUtils.kt @@ -19,18 +19,19 @@ package io.github.chrisimx.scanbridge.util -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 androidx.compose.ui.platform.Clipboard +import io.github.chrisimx.scanbridge.clipboard.toClipEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.copy +import scanbridge.composeui.generated.resources.error_while_retrieving_page fun String.truncate(maxLength: Int): String = if (this.length <= maxLength) { this @@ -53,21 +54,23 @@ data class CustomSnackbarVisuals( val type: SnackbarType = SnackbarType.DEFAULT ) : SnackbarVisuals -fun snackbarErrorRetrievingPage( + fun snackbarErrorRetrievingPage( error: String, scope: CoroutineScope, - context: Context, snackbarHostState: SnackbarHostState, - action: Boolean = true + action: Boolean = true, + clipboard: Clipboard ) { - snackBarError( - context.getString(R.string.error_while_retrieving_page, error.truncate(128)), - scope, - context, - snackbarHostState, - action, - error - ) + scope.launch { + snackBarError( + getString(Res.string.error_while_retrieving_page, error.truncate(128)), + scope, + snackbarHostState, + action, + error, + clipboard + ) + } } suspend fun SnackbarHostState.showCustomSnackbar( @@ -89,29 +92,22 @@ suspend fun SnackbarHostState.showCustomSnackbar( fun snackBarError( error: String, scope: CoroutineScope, - context: Context, snackbarHostState: SnackbarHostState, action: Boolean = true, - copyData: String? = null + copyData: String? = null, + clipboard: Clipboard ) { scope.launch { val result = snackbarHostState.showCustomSnackbar( error, SnackbarType.ERROR, - if (action) context.getString(R.string.copy) else null, + if (action) getString(Res.string.copy) else null, SnackbarDuration.Indefinite, true ) when (result) { SnackbarResult.ActionPerformed -> { - val systemClipboard = - context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - systemClipboard.setPrimaryClip( - ClipData.newPlainText( - context.getString(R.string.error), - copyData ?: error - ) - ) + clipboard.setClipEntry((copyData ?: error).toClipEntry()) } SnackbarResult.Dismissed -> {} diff --git a/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.jvm.kt b/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.jvm.kt new file mode 100644 index 00000000..f560cb92 --- /dev/null +++ b/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.jvm.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.clipboard + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry + +@OptIn(ExperimentalComposeUiApi::class) +actual fun String.toClipEntry(): ClipEntry { + return ClipEntry(java.awt.datatransfer.StringSelection(this)) +} diff --git a/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.jvm.kt b/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.jvm.kt new file mode 100644 index 00000000..9e741484 --- /dev/null +++ b/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.jvm.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge.platformhelper + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } +} diff --git a/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.jvm.kt b/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.jvm.kt new file mode 100644 index 00000000..7a06079f --- /dev/null +++ b/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.jvm.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.vinceglb.filekit.PlatformFile + +actual suspend fun platformShareFile(file: PlatformFile) { + throw NotImplementedError("Desktop does not support sharing files") +} diff --git a/composeUI/src/mobileMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.mobile.kt b/composeUI/src/mobileMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.mobile.kt new file mode 100644 index 00000000..5138a8f2 --- /dev/null +++ b/composeUI/src/mobileMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.mobile.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.shareFile + +actual suspend fun platformShareFile(file: PlatformFile) { + FileKit.shareFile(file) +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index fd76013b..4de12fd9 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,3 +1,4 @@ +import com.codingfeline.buildkonfig.compiler.FieldSpec import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget @@ -9,6 +10,7 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.room) + alias(libs.plugins.buildkonfig) } kotlin { @@ -43,6 +45,8 @@ kotlin { iosArm64() iosSimulatorArm64() + applyDefaultHierarchyTemplate() + sourceSets { all { languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") @@ -51,6 +55,7 @@ kotlin { api("com.diamondedge:logging:2.1.0") api(libs.koin.core) api(libs.koin.annotations) + api(libs.koin.core.viewmodel) api(libs.kotlinx.coroutines) api(libs.kotlinx.serialization.json) api(libs.ktor.client.core) @@ -59,12 +64,15 @@ kotlin { api(libs.wsdkt) api(libs.anyscanCore) api(libs.nonThrowingKtor) + api(libs.filekit.core) // Room deps implementation(libs.androidx.room.runtime) implementation(libs.androidx.sqlite.bundled) - api("com.rickclephas.kmp:kmp-observableviewmodel-core:1.0.3") + api(libs.kmp.observable.viewmodel) + implementation(libs.kmp.native.zip) + } commonTest.dependencies { @@ -72,13 +80,17 @@ kotlin { } androidMain.dependencies { - api(libs.ktor.client.okhttp) } val jvmAndAndroid by creating { dependsOn(commonMain.get()) } + jvmAndAndroid.dependencies { + api(libs.ktor.client.okhttp) + api(libs.itext7.core) + } + androidMain.get().dependsOn(jvmAndAndroid) jvmMain.get().dependsOn(jvmAndAndroid) @@ -92,6 +104,7 @@ kotlin { } jvmMain.dependencies { + implementation(libs.jmdns) } } @@ -118,3 +131,22 @@ dependencies { add("kspIosSimulatorArm64", libs.androidx.room.compiler) add("kspIosArm64", libs.androidx.room.compiler) } + +koinCompiler { + compileSafety = true +} + +val gitHashProvider = providers.exec { + commandLine("git", "rev-parse", "--short", "HEAD") + isIgnoreExitValue = true +}.standardOutput.asText.map { it.trim() } + +buildkonfig { + packageName = "io.github.chrisimx.scanbridge" + + defaultConfigs { + buildConfigField(FieldSpec.Type.STRING, "scanbridgeVersion", version.toString()) + buildConfigField(FieldSpec.Type.INT, "scanbridgeVersionCode", providers.gradleProperty("scanbridgeVersionCode").get()) + buildConfigField(FieldSpec.Type.STRING, "gitCommitHash", gitHashProvider.get()) + } +} diff --git a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt index dbd38967..982a7b91 100644 --- a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt +++ b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt @@ -1,12 +1,11 @@ package io.github.chrisimx.scanbridge -import AndroidMdnsDiscoverService import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.KmLogScanBridgeLoggerFactory import java.net.ServerSocket import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException diff --git a/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt similarity index 95% rename from core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt index 8809a07b..aa1cac4c 100644 --- a/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt @@ -1,13 +1,16 @@ +package io.github.chrisimx.scanbridge + import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build import android.os.ext.SdkExtensions import androidx.annotation.RequiresExtension +import toMultiplatformIpAddress import io.github.chrisimx.scanbridge.model.IpAddress import io.github.chrisimx.scanbridge.model.MdnsService import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import java.util.concurrent.ForkJoinPool import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.flow.MutableStateFlow @@ -197,7 +200,11 @@ class AndroidMdnsDiscoverService(val appContext: Context, val loggerFactory: Sca val serviceIdentifier = getServiceUniqueIdentifier(originalServiceInfo) serviceInfoCallbacks.remove(this) - nsdManager.unregisterServiceInfoCallback(this) + try { + nsdManager.unregisterServiceInfoCallback(this) + } catch (e: Exception) { + logger.error { "Failed to unregister ServiceInfoCallback (${this.hashCode()}): $e" } + } _foundServices.update { val updateMap = it.toMutableMap() updateMap.remove(serviceIdentifier) diff --git a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt similarity index 89% rename from core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt index f21ca143..04038ebb 100644 --- a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt @@ -1,7 +1,9 @@ +package io.github.chrisimx.scanbridge + 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.logging.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler class AndroidMulticastLockHandler(val application: Application, private val loggerFactory: ScanBridgeLoggerFactory) : MulticastLockHandler { diff --git a/core/src/androidMain/kotlin/AndroidScanBridgeDbBuilderFactory.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidScanBridgeDbBuilderFactory.kt similarity index 93% rename from core/src/androidMain/kotlin/AndroidScanBridgeDbBuilderFactory.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidScanBridgeDbBuilderFactory.kt index 8f522fea..3934321f 100644 --- a/core/src/androidMain/kotlin/AndroidScanBridgeDbBuilderFactory.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidScanBridgeDbBuilderFactory.kt @@ -1,3 +1,5 @@ +package io.github.chrisimx.scanbridge + import android.content.Context import androidx.room.Room import androidx.room.RoomDatabase diff --git a/core/src/androidMain/kotlin/TimingEventListener.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TimingEventListener.kt similarity index 97% rename from core/src/androidMain/kotlin/TimingEventListener.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TimingEventListener.kt index 55dec8ad..75d2bc22 100644 --- a/core/src/androidMain/kotlin/TimingEventListener.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TimingEventListener.kt @@ -1,3 +1,5 @@ +package io.github.chrisimx.scanbridge + import java.io.IOException import java.net.InetAddress import java.net.InetSocketAddress diff --git a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt new file mode 100644 index 00000000..53b095d6 --- /dev/null +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt @@ -0,0 +1,66 @@ +package io.github.chrisimx.scanbridge.cropfeature + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import io.github.chrisimx.scanbridge.model.Rect +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.sink +import io.github.vinceglb.filekit.source +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.asInputStream +import kotlinx.io.asOutputStream +import kotlinx.io.buffered + +class AndroidImageCropService( + loggerFactory: ScanBridgeLoggerFactory +) : ImageCropService { + private val logger = loggerFactory.withClass(this::class) + + override suspend fun crop(sourceFile: PlatformFile, outputFile: PlatformFile, cropRect: Rect): Boolean = withContext(Dispatchers.IO) { + val sourceFileInputStream = sourceFile + .source() + .buffered() + .asInputStream() + + val sourceBitmap = sourceFileInputStream.use { + BitmapFactory.decodeStream(it) + } + if (sourceBitmap == null) { + logger.error { + "Could not decode source bitmap for cropping" + } + return@withContext false + } + + val startX = cropRect.left * sourceBitmap.width + val startY = cropRect.top * sourceBitmap.height + + val width = (cropRect.right - cropRect.left) * sourceBitmap.width + val height = (cropRect.bottom - cropRect.top) * sourceBitmap.height + + val croppedBitmap = Bitmap.createBitmap( + sourceBitmap, + startX.roundToInt(), + startY.roundToInt(), + width.roundToInt(), + height.roundToInt() + ) + sourceBitmap.recycle() + + val outputFileOutputStream = outputFile + .sink(append = false) + .buffered() + .asOutputStream() + + outputFileOutputStream.use { + croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) + } + + croppedBitmap.recycle() + + return@withContext true + } +} diff --git a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt new file mode 100644 index 00000000..8f68e47d --- /dev/null +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt @@ -0,0 +1,58 @@ +package io.github.chrisimx.scanbridge.imagerotation + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.sink +import io.github.vinceglb.filekit.source +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.asInputStream +import kotlinx.io.asOutputStream +import kotlinx.io.buffered + +class AndroidImageRotationService( + loggerFactory: ScanBridgeLoggerFactory +) : ImageRotationService { + val logger = loggerFactory.withClass(this::class) + + override suspend fun rotate90ToRight( + sourceFile: PlatformFile, + outputFile: PlatformFile + ): Boolean = withContext(Dispatchers.IO) { + val sourceFileInputStream = sourceFile + .source() + .buffered() + .asInputStream() + + val sourceBitmap = sourceFileInputStream.use { + BitmapFactory.decodeStream(it) + } + if (sourceBitmap == null) { + logger.error { + "Could not decode source bitmap for rotating" + } + return@withContext false + } + + val matrix = Matrix().apply { postRotate(90F) } + val rotatedBitmap = Bitmap.createBitmap(sourceBitmap, 0, 0, sourceBitmap.width, sourceBitmap.height, matrix, true) + + sourceBitmap.recycle() + + val rotatedFileOutputStream = outputFile + .sink(append = false) + .buffered() + .asOutputStream() + + rotatedFileOutputStream.use { + rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) + } + rotatedBitmap.recycle() + + return@withContext true + } + +} diff --git a/core/src/appleMain/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.apple.kt b/core/src/appleMain/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.apple.kt new file mode 100644 index 00000000..ed7f8c50 --- /dev/null +++ b/core/src/appleMain/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.apple.kt @@ -0,0 +1,20 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.absolutePath + +import kotlinx.cinterop.* +import platform.CoreGraphics.* +import platform.Foundation.* +import platform.ImageIO.* +import platform.UniformTypeIdentifiers.UTTypePNG +import kotlin.uuid.Uuid +import platform.CoreFoundation.CFURLRef + +@OptIn(ExperimentalForeignApi::class) +actual fun extractPdfImages( + pdf: PlatformFile, + outputDir: PlatformFile +): List { + TODO() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt deleted file mode 100644 index 07b004a2..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 SCAN_PROTOCOLS = module { - single() bind ScanningProtocol::class - single() bind ScanningProtocol::class -} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt index ee36dbdc..0a18be92 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt @@ -41,8 +41,10 @@ import io.github.chrisimx.scanbridge.model.NumberValidationResult import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.model.UIInputSourceType import io.github.chrisimx.scanbridge.model.toUIInputSourceType -import io.github.chrisimx.scanbridge.ports.LocaleProvider -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.localization.LocaleProvider +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.paperformat.PaperFormat +import io.github.chrisimx.scanbridge.paperformat.PaperFormatProvider import io.github.chrisimx.scanbridge.util.derived import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/KoinModuleAppSettings.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/KoinModuleAppSettings.kt new file mode 100644 index 00000000..bd16941f --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/KoinModuleAppSettings.kt @@ -0,0 +1,11 @@ +package io.github.chrisimx.scanbridge.appsettings + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single +import org.koin.plugin.module.dsl.viewModel + +val KOIN_MODULE_APP_SETTINGS = module { + single() bind AppSettingsRepository::class + viewModel() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/BuildInfoProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/BuildInfoProvider.kt index a7226125..1fb67d9e 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/BuildInfoProvider.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/BuildInfoProvider.kt @@ -1,11 +1,15 @@ package io.github.chrisimx.scanbridge.buildinfo import io.github.chrisimx.scanbridge.model.Platform +import io.github.chrisimx.scanbridge.BuildKonfig interface BuildInfoProvider { val versionName: String + get() = BuildKonfig.scanbridgeVersion val versionCode: Int + get() = BuildKonfig.scanbridgeVersionCode val commit: String + get() = BuildKonfig.gitCommitHash val edition: ScanBridgeEdition val isDebugBuild: Boolean val platform: Platform diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/ScanBridgeEdition.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/ScanBridgeEdition.kt index b16665d6..c78d7069 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/ScanBridgeEdition.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/buildinfo/ScanBridgeEdition.kt @@ -3,5 +3,6 @@ package io.github.chrisimx.scanbridge.buildinfo enum class ScanBridgeEdition { FDROID, PLAYSTORE, - IOS + IOS, + DESKTOP } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/CropScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/CropScreenViewModel.kt new file mode 100644 index 00000000..6cf4bd64 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/CropScreenViewModel.kt @@ -0,0 +1,75 @@ +package io.github.chrisimx.scanbridge.cropfeature + +import com.rickclephas.kmp.observableviewmodel.ViewModel +import com.rickclephas.kmp.observableviewmodel.launch +import com.rickclephas.kmp.observableviewmodel.stateIn +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.model.Rect +import kotlin.uuid.Uuid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.InjectedParam + +sealed interface CropState { + data object Idle : CropState + data object Processing : CropState + data object Finished : CropState +} + +class CropScreenViewModel( + @InjectedParam + val scannedPageId: Uuid, + scanBridgeDb: ScanBridgeDb, + val finishCropUseCase: FinishCropUseCase +) : ViewModel() { + val scannedPagesDao = scanBridgeDb.scannedPageDao() + + private val _cropRect = MutableStateFlow( + Rect(0f, 0f, 1f, 1f) + ) + val cropRect = _cropRect.asStateFlow() + + val scannedPageMetadata = scannedPagesDao.getByScanIdFlowNullable(scannedPageId) + + val croppedImageFilePath = scannedPageMetadata.map { + it?.filePath + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + fun setCropRect(rect: Rect) { + _cropRect.value = rect + } + + private val _cropState = MutableStateFlow(CropState.Idle) + val cropState = _cropState.asStateFlow() + + fun finishCrop() = viewModelScope.launch { + if (!_cropState.compareAndSet( + CropState.Idle, + CropState.Processing + ) + ) { + return@launch + } + + var success = false + + try { + val currentCropRect = cropRect.value + val currentScannedPage = scannedPagesDao.getByScanId(scannedPageId) ?: return@launch + + success = finishCropUseCase( + currentScannedPage, + currentCropRect + ) + } finally { + if (success) { + _cropState.value = CropState.Finished + } else { + _cropState.value = CropState.Idle + } + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt new file mode 100644 index 00000000..1a84e4bc --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt @@ -0,0 +1,55 @@ +package io.github.chrisimx.scanbridge.cropfeature + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.chrisimx.scanbridge.model.Rect +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.util.getEditedFile +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.delete +import io.github.vinceglb.filekit.path + +class FinishCropUseCase( + private val imageCropService: ImageCropService, + scanBridgeDb: ScanBridgeDb, + loggerFactory: ScanBridgeLoggerFactory +) { + private val scannedPageDao = scanBridgeDb.scannedPageDao() + private val logger = loggerFactory.withClass(this::class) + + suspend operator fun invoke( + page: ScannedPage, + cropRect: Rect, + ): Boolean { + val scanFile = PlatformFile(page.filePath) + + // Determine an output for the cropped image + val editFile = scanFile.getEditedFile() + + if (editFile == null) { + logger.error { "Could not determine output file name for cropped image" } + return false + } + + val successfulCrop = imageCropService.crop( + sourceFile = scanFile, + outputFile = editFile, + cropRect = cropRect, + ) + + if (!successfulCrop) { + return false + } + + scannedPageDao.update( + page.copy( + filePath = editFile.path + ) + ) + + // Delete the original file + scanFile.delete() + + return true + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/ImageCropService.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/ImageCropService.kt new file mode 100644 index 00000000..dd6ef1ee --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/ImageCropService.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.cropfeature + +import io.github.chrisimx.scanbridge.model.Rect +import io.github.vinceglb.filekit.PlatformFile + +interface ImageCropService { + suspend fun crop( + sourceFile: PlatformFile, + outputFile: PlatformFile, + cropRect: Rect, + ): Boolean +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/KoinModuleCropFeature.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/KoinModuleCropFeature.kt new file mode 100644 index 00000000..b445ccc5 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/KoinModuleCropFeature.kt @@ -0,0 +1,11 @@ +package io.github.chrisimx.scanbridge.cropfeature + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single +import org.koin.plugin.module.dsl.viewModel + +val KOIN_MODULE_CROP_FEAT = module { + viewModel() + single() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/KoinModuleDatabase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/KoinModuleDatabase.kt new file mode 100644 index 00000000..25c40a0e --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/KoinModuleDatabase.kt @@ -0,0 +1,15 @@ +package io.github.chrisimx.scanbridge.db + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single +import org.koin.plugin.module.dsl.create + +fun createScanBridgeDb(factory: ScanBridgeDbFactory): ScanBridgeDb = factory.createInstance() + +val KOIN_MODULE_DATABASE = module { + single() bind ScanBridgeDbFactory::class + single { + create(::createScanBridgeDb) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/ScannedPageDao.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/ScannedPageDao.kt index c9786d7c..b6f72a05 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/ScannedPageDao.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/ScannedPageDao.kt @@ -27,6 +27,9 @@ interface ScannedPageDao { @Query("SELECT * FROM scannedpages WHERE scanId = :id") fun getByScanIdFlow(id: Uuid): Flow + @Query("SELECT * FROM scannedpages WHERE scanId = :id") + fun getByScanIdFlowNullable(id: Uuid): Flow + @Query("SELECT * FROM scannedpages WHERE scanId = :id") suspend fun getByScanId(id: Uuid): ScannedPage? diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/KoinRoomMigrationsModule.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/KoinRoomMigrationsModule.kt index 6d2a37c5..7f648dcb 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/KoinRoomMigrationsModule.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/KoinRoomMigrationsModule.kt @@ -3,7 +3,7 @@ package io.github.chrisimx.scanbridge.db.migrations import org.koin.dsl.module import org.koin.plugin.module.dsl.single -val ROOM_MIGRATIONS = module { +val KOIN_MODULE_ROOM_MIGRATIONS = module { single() single() } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion5To6.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion5To6.kt index 0752890a..faffa5df 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion5To6.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion5To6.kt @@ -5,7 +5,7 @@ import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider import io.github.chrisimx.scanbridge.model.Platform -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository import io.github.chrisimx.scanbridge.startupmessages.StartupMessage import kotlinx.coroutines.flow.first diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion6To7.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion6To7.kt index 23e31fb9..d32a2616 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion6To7.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion6To7.kt @@ -7,7 +7,7 @@ import io.github.chrisimx.scanbridge.ScanSettingsJson import io.github.chrisimx.scanbridge.appsettings.D2RAppSettingsMigrationDataSource import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider import io.github.chrisimx.scanbridge.model.Platform -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.savelastusedscansettings.D2RLastUsedScanSettingsMigrationSource import kotlinx.coroutines.runBlocking import org.koin.core.scope.Scope diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportDirectoryProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportDirectoryProvider.kt new file mode 100644 index 00000000..54d5a651 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportDirectoryProvider.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.chrisimx.scanbridge.filesystem.StandardDirectoryProvider +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.div + +class DefaultExportDirectoryProvider( + standardDirectoryProvider: StandardDirectoryProvider +) : ExportDirectoryProvider { + override val exportDirectory = + standardDirectoryProvider.appFilesDirectory / "export" +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportFileManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportFileManager.kt new file mode 100644 index 00000000..46f53aea --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportFileManager.kt @@ -0,0 +1,27 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.createDirectories +import io.github.vinceglb.filekit.div +import kotlin.time.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime + +class DefaultExportFileManager( + val exportDirectoryProvider: ExportDirectoryProvider +) : ExportFileManager { + override fun createFile(extension: String): PlatformFile { + val exportDirectory = exportDirectoryProvider.exportDirectory + val now = Clock.System.now() + val localDateTime = now.toLocalDateTime(TimeZone.currentSystemDefault()) + val formattedDateTime = localDateTime.format(LocalDateTime.Formats.ISO) + + val exportFileName = "export-$formattedDateTime.$extension" + + exportDirectory.createDirectories() + + return exportDirectory / exportFileName + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportCapabilitiesProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportCapabilitiesProvider.kt new file mode 100644 index 00000000..b267cb39 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportCapabilitiesProvider.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.chrisimx.scanbridge.scanning.FileSaveType + +interface ExportCapabilitiesProvider { + val supportedFileSaveTypes: Set +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportDirectoryProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportDirectoryProvider.kt new file mode 100644 index 00000000..726b617a --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportDirectoryProvider.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.vinceglb.filekit.PlatformFile + +interface ExportDirectoryProvider { + val exportDirectory: PlatformFile +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportFileManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportFileManager.kt new file mode 100644 index 00000000..ce2eb2ec --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportFileManager.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.vinceglb.filekit.PlatformFile + +interface ExportFileManager { + fun createFile(extension: String): PlatformFile +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModule.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModule.kt new file mode 100644 index 00000000..0fa7263d --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModule.kt @@ -0,0 +1,10 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.vinceglb.filekit.PlatformFile + +interface ExportModule { + val type: ExportModuleType + + suspend fun export(pagesToExport: List): PlatformFile +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleManager.kt new file mode 100644 index 00000000..89c5e570 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleManager.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.export + +import kotlinx.coroutines.flow.StateFlow + +interface ExportModuleManager { + fun getExportModuleByType(type: ExportModuleType): ExportModule? + fun getAllExportModulesFlow(): StateFlow> +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleType.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleType.kt new file mode 100644 index 00000000..aabfe79d --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleType.kt @@ -0,0 +1,6 @@ +package io.github.chrisimx.scanbridge.export + +enum class ExportModuleType { + PDF, + ZIP +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinExportModuleManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinExportModuleManager.kt new file mode 100644 index 00000000..3dec090b --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinExportModuleManager.kt @@ -0,0 +1,23 @@ +package io.github.chrisimx.scanbridge.export + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.scope.Scope + +class KoinExportModuleManager( + koinScope: Scope +) : ExportModuleManager { + val exportModules = koinScope.getAll() + val exportModuleById = exportModules.associateBy { it.type } + + val exportModulesStateFlow = MutableStateFlow(exportModules).asStateFlow() + + override fun getExportModuleByType(type: ExportModuleType): ExportModule? { + return exportModuleById[type]?.let { return it } + } + + override fun getAllExportModulesFlow(): StateFlow> { + return exportModulesStateFlow + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt new file mode 100644 index 00000000..fbf2fd12 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt @@ -0,0 +1,23 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.chrisimx.scanbridge.export.zip.KmpNativeZipBasedZipService +import io.github.chrisimx.scanbridge.export.zip.ZipExportModule +import io.github.chrisimx.scanbridge.export.zip.ZipService +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_EXPORT = module { + // TODO: Pdf export module and dependencies + + // ZIP export module and dependencies + single() bind ZipService::class + single() bind ExportModule::class + + // Export file management + single() bind ExportDirectoryProvider::class + single() bind ExportFileManager::class + + // Provides the export modules to the app + single() bind ExportModuleManager::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt new file mode 100644 index 00000000..2e3c7503 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge.export + +import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.vinceglb.filekit.PlatformFile + +open class NoopExportModule( + override val type: ExportModuleType +) : ExportModule { + override suspend fun export(pagesToExport: List): PlatformFile { + throw NotImplementedError("No-op export module") + } +} + +class PdfNoopExportModule : NoopExportModule(ExportModuleType.PDF) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/KmpNativeZipBasedZipService.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/KmpNativeZipBasedZipService.kt new file mode 100644 index 00000000..becefc83 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/KmpNativeZipBasedZipService.kt @@ -0,0 +1,83 @@ +package io.github.chrisimx.scanbridge.export.zip + +import io.github.chrisimx.kmpnativezip.ZipEntry +import io.github.chrisimx.kmpnativezip.ZipWriter +import io.github.chrisimx.kmpnativezip.compression.impl.DeflateCompressionAlgorithm +import io.github.chrisimx.kmpnativezip.compression.impl.StoreCompressionAlgorithm +import io.github.chrisimx.kmpnativezip.dostime.DOSDate +import io.github.chrisimx.kmpnativezip.dostime.DOSTime +import io.github.chrisimx.kmpnativezip.io.seekable.seekableSink +import io.github.chrisimx.kmpnativezip.zipprimitives.CompressionMethod +import io.github.chrisimx.scanbridge.model.FileToBeExported +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.path +import io.github.vinceglb.filekit.source +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime +import kotlinx.io.buffered +import kotlinx.io.files.Path + +class KmpNativeZipBasedZipService() : ZipService { + override fun zipFlat( + files: List, + output: PlatformFile, + mapFileName: (PlatformFile) -> String + ) { + val outputFile = Path(output.path).seekableSink() + + val exportTime = Clock.System.now() + + outputFile.use { + ZipWriter( + outputFile, + listOf(StoreCompressionAlgorithm(), DeflateCompressionAlgorithm()) + ).use { writer -> + files.forEach { fileExportInfo -> + val file = fileExportInfo.file + val fileName = fileExportInfo.exportedFileName + + // Check filename + require('/' !in fileName) { "Filename must not contain a slash for zipFlat" } + + val zipEntry = zipEntryWithFilename(fileName, exportTime) + + writer.addFile(zipEntry).use { zipSink -> + file.source().buffered().use { originalFileSource -> + originalFileSource.transferTo(zipSink) + } + } + + writer.finishEntry() + } + } + } + } + + private fun zipEntryWithFilename(filename: String, exportTime: Instant): ZipEntry { + val exportLocaleTime = exportTime.toLocalDateTime(TimeZone.currentSystemDefault()) + + val dosTime = DOSTime( + exportLocaleTime.second.toUInt(), + exportLocaleTime.minute.toUInt(), + exportLocaleTime.hour.toUInt() + ) + + val dosDate = DOSDate( + exportLocaleTime.day.toUInt(), + exportLocaleTime.month.number.toUInt(), + exportLocaleTime.year.toUInt() + ) + + return ZipEntry( + compressionMethod = CompressionMethod.STORED, + lastModifiedDate = dosDate, + lastModifiedTime = dosTime, + fileName = filename, + extraFields = emptyList() + ) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipExportModule.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipExportModule.kt new file mode 100644 index 00000000..25ca1692 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipExportModule.kt @@ -0,0 +1,32 @@ +package io.github.chrisimx.scanbridge.export.zip + +import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.chrisimx.scanbridge.export.ExportDirectoryProvider +import io.github.chrisimx.scanbridge.export.ExportFileManager +import io.github.chrisimx.scanbridge.export.ExportModule +import io.github.chrisimx.scanbridge.export.ExportModuleType +import io.github.chrisimx.scanbridge.model.FileToBeExported +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.name + +class ZipExportModule( + val zipService: ZipService, + val exportFileManager: ExportFileManager +) : ExportModule { + override val type: ExportModuleType = ExportModuleType.ZIP + + override suspend fun export(pagesToExport: List): PlatformFile { + val filesToBeExported = pagesToExport.map { + val pageFile = PlatformFile(it.filePath) + FileToBeExported( + pageFile, + it.outputName ?: pageFile.name + ) + } + + val exportFile = exportFileManager.createFile("zip") + zipService.zipFlat(filesToBeExported, exportFile) + return exportFile + } + +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipService.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipService.kt new file mode 100644 index 00000000..8030952e --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipService.kt @@ -0,0 +1,10 @@ +package io.github.chrisimx.scanbridge.export.zip + +import io.github.chrisimx.scanbridge.model.FileToBeExported +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.name + + +interface ZipService { + fun zipFlat(files: List, output: PlatformFile, mapFileName: (PlatformFile) -> String = { it.name }) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileKitStandardDirectoryProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileKitStandardDirectoryProvider.kt new file mode 100644 index 00000000..ba73585e --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileKitStandardDirectoryProvider.kt @@ -0,0 +1,16 @@ +package io.github.chrisimx.scanbridge.filesystem + +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.cacheDir +import io.github.vinceglb.filekit.databasesDir +import io.github.vinceglb.filekit.filesDir + +class FileKitStandardDirectoryProvider : StandardDirectoryProvider { + override val appFilesDirectory: PlatformFile + get() = FileKit.filesDir + override val appCacheDirectory: PlatformFile + get() = FileKit.cacheDir + override val databaseDirectory: PlatformFile + get() = FileKit.databasesDir +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KoinModuleFilesystem.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KoinModuleFilesystem.kt new file mode 100644 index 00000000..0b9ec516 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KoinModuleFilesystem.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.filesystem + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_FILESYSTEM = module { + single() bind StandardDirectoryProvider::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/StandardDirectoryProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/StandardDirectoryProvider.kt new file mode 100644 index 00000000..5e4bf470 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/StandardDirectoryProvider.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.filesystem + +import io.github.vinceglb.filekit.PlatformFile + +interface StandardDirectoryProvider { + val appFilesDirectory: PlatformFile + val appCacheDirectory: PlatformFile + val databaseDirectory: PlatformFile +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/ImageRotationService.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/ImageRotationService.kt new file mode 100644 index 00000000..cee7fc0c --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/ImageRotationService.kt @@ -0,0 +1,10 @@ +package io.github.chrisimx.scanbridge.imagerotation + +import io.github.vinceglb.filekit.PlatformFile + +interface ImageRotationService { + suspend fun rotate90ToRight( + sourceFile: PlatformFile, + outputFile: PlatformFile, + ): Boolean +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt new file mode 100644 index 00000000..50c8c409 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt @@ -0,0 +1,43 @@ +package io.github.chrisimx.scanbridge.imagerotation + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.util.getEditedFile +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.delete +import io.github.vinceglb.filekit.path +import kotlin.uuid.Uuid + +class RotateScanUseCase( + scanBridgeDb: ScanBridgeDb, + loggerFactory: ScanBridgeLoggerFactory, + val imageRotationService: ImageRotationService +) { + private val scannedPageDao = scanBridgeDb.scannedPageDao() + private val logger = loggerFactory.withClass(this::class) + + suspend fun rotateScan(scannedPageId: Uuid) { + val scannedPage = scannedPageDao.getByScanId(scannedPageId) ?: return + val scanFile = PlatformFile(scannedPage.filePath) + val editFile = scanFile.getEditedFile() + + if (editFile == null) { + logger.error { "Could not determine output file name for rotated image" } + return + } + + val successRotating = imageRotationService.rotate90ToRight(scanFile, editFile) + + if (!successRotating) { + return + } + + scannedPageDao.update( + scannedPage.copy( + filePath = editFile.path + ) + ) + + scanFile.delete() + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/KoinModuleInitialScanSettings.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/KoinModuleInitialScanSettings.kt new file mode 100644 index 00000000..d187f9df --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/KoinModuleInitialScanSettings.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.initialscansettings + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_INITIAL_SCAN_SETTINGS = module { + single() bind InitialScanSettingsProvider::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt new file mode 100644 index 00000000..f4806dd6 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt @@ -0,0 +1,44 @@ +package io.github.chrisimx.scanbridge.koin + +import io.github.chrisimx.scanbridge.appsettings.KOIN_MODULE_APP_SETTINGS +import io.github.chrisimx.scanbridge.cropfeature.KOIN_MODULE_CROP_FEAT +import io.github.chrisimx.scanbridge.protocol.KOIN_MODULE_SCAN_PROTOCOLS +import io.github.chrisimx.scanbridge.db.migrations.KOIN_MODULE_ROOM_MIGRATIONS +import io.github.chrisimx.scanbridge.export.KOIN_MODULE_EXPORT +import io.github.chrisimx.scanbridge.filesystem.KOIN_MODULE_FILESYSTEM +import io.github.chrisimx.scanbridge.logging.KOIN_MODULE_LOGGING +import io.github.chrisimx.scanbridge.savelastroute.KOIN_MODULE_LAST_ROUTE_STORE +import io.github.chrisimx.scanbridge.scanning.KOIN_MODULE_SCANNING_USE_CASES +import io.github.chrisimx.scanbridge.db.KOIN_MODULE_DATABASE +import io.github.chrisimx.scanbridge.initialscansettings.KOIN_MODULE_INITIAL_SCAN_SETTINGS +import io.github.chrisimx.scanbridge.paperformat.KOIN_MODULE_PAPER_FORMAT +import io.github.chrisimx.scanbridge.protocol.KOIN_MODULE_SCANNING_PROTOCOL_FEAT +import io.github.chrisimx.scanbridge.savelastusedscansettings.KOIN_MODULE_LAST_USED_SCAN_SETTINGS_STORE +import io.github.chrisimx.scanbridge.scannerdiscovery.KOIN_MODULE_SCANNER_DISCOVERY +import io.github.chrisimx.scanbridge.scanning.KOIN_MODULE_SCANNING_SCREEN +import io.github.chrisimx.scanbridge.scanning.KOIN_MODULE_SCAN_JOB_MANAGEMENT +import io.github.chrisimx.scanbridge.startupmessages.KOIN_MODULE_STARTUP_MESSAGES +import org.koin.dsl.module + +val KOIN_MODULE_COMMON = module { + includes( + KOIN_MODULE_SCAN_JOB_MANAGEMENT, + KOIN_MODULE_SCANNING_SCREEN, + KOIN_MODULE_SCANNING_USE_CASES, + KOIN_MODULE_EXPORT, + KOIN_MODULE_SCANNING_PROTOCOL_FEAT, + KOIN_MODULE_SCAN_PROTOCOLS, + KOIN_MODULE_ROOM_MIGRATIONS, + KOIN_MODULE_FILESYSTEM, + KOIN_MODULE_LOGGING, + KOIN_MODULE_LAST_ROUTE_STORE, + KOIN_MODULE_LAST_USED_SCAN_SETTINGS_STORE, + KOIN_MODULE_DATABASE, + KOIN_MODULE_STARTUP_MESSAGES, + KOIN_MODULE_APP_SETTINGS, + KOIN_MODULE_SCANNER_DISCOVERY, + KOIN_MODULE_PAPER_FORMAT, + KOIN_MODULE_INITIAL_SCAN_SETTINGS, + KOIN_MODULE_CROP_FEAT + ) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/LocaleProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localization/LocaleProvider.kt similarity index 83% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/LocaleProvider.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localization/LocaleProvider.kt index 903c7a32..bbfe14c0 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/LocaleProvider.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localization/LocaleProvider.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.ports +package io.github.chrisimx.scanbridge.localization import io.github.chrisimx.scanbridge.model.Locale import kotlinx.coroutines.flow.StateFlow diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/infrastructure/KmLogScanBridgeLogger.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KmLogScanBridgeLogger.kt similarity index 87% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/infrastructure/KmLogScanBridgeLogger.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KmLogScanBridgeLogger.kt index 5a4d04b2..85bc0113 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/infrastructure/KmLogScanBridgeLogger.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KmLogScanBridgeLogger.kt @@ -1,8 +1,7 @@ -package io.github.chrisimx.scanbridge.infrastructure +package io.github.chrisimx.scanbridge.logging import com.diamondedge.logging.KmLog import com.diamondedge.logging.logging -import io.github.chrisimx.scanbridge.ports.ScanBridgeLogger class KmLogScanBridgeLogger(tag: String) : ScanBridgeLogger { private val logger: KmLog = logging(tag) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/infrastructure/KmLogScanBridgeLoggerFactory.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KmLogScanBridgeLoggerFactory.kt similarity index 59% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/infrastructure/KmLogScanBridgeLoggerFactory.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KmLogScanBridgeLoggerFactory.kt index 0e562d33..a5c000c2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/infrastructure/KmLogScanBridgeLoggerFactory.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KmLogScanBridgeLoggerFactory.kt @@ -1,8 +1,5 @@ -package io.github.chrisimx.scanbridge.infrastructure +package io.github.chrisimx.scanbridge.logging -import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLogger -import io.github.chrisimx.scanbridge.ports.ScanBridgeLogger -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import kotlin.reflect.KClass class KmLogScanBridgeLoggerFactory : ScanBridgeLoggerFactory { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt new file mode 100644 index 00000000..2a92e499 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.logging + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_LOGGING = module { + single() bind ScanBridgeLoggerFactory::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanBridgeLogger.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/ScanBridgeLogger.kt similarity index 93% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanBridgeLogger.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/ScanBridgeLogger.kt index 7c47afb8..259112fa 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanBridgeLogger.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/ScanBridgeLogger.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.ports +package io.github.chrisimx.scanbridge.logging interface ScanBridgeLogger { /** Log a verbose message */ diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanBridgeLoggerFactory.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/ScanBridgeLoggerFactory.kt similarity index 63% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanBridgeLoggerFactory.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/ScanBridgeLoggerFactory.kt index 05f88004..fb1a2658 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanBridgeLoggerFactory.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/ScanBridgeLoggerFactory.kt @@ -1,6 +1,5 @@ -package io.github.chrisimx.scanbridge.ports +package io.github.chrisimx.scanbridge.logging -import io.github.chrisimx.scanbridge.ports.ScanBridgeLogger import kotlin.reflect.KClass interface ScanBridgeLoggerFactory { 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 6e04d364..91c538fc 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,6 +1,6 @@ package io.github.chrisimx.scanbridge.model -import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.protocol.ScannerCapabilitiesResult import io.ktor.http.Url data class DiscoveredScanner( diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/EditedCustomScanner.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/EditedCustomScanner.kt similarity index 51% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/EditedCustomScanner.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/EditedCustomScanner.kt index 5c19bc0f..da691116 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/model/EditedCustomScanner.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/EditedCustomScanner.kt @@ -1,7 +1,12 @@ -package io.github.chrisimx.scanbridge.data.model +package io.github.chrisimx.scanbridge.model import io.github.chrisimx.scanbridge.db.entities.CustomScanner +/** + * A custom scanner that is either new or being edited. + * + * Used for describing whether we are currently editing a custom scanner or creating a new one. + */ sealed class EditedCustomScanner { data object New : EditedCustomScanner() data class EditingOld(val scanner: CustomScanner) : EditedCustomScanner() diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/FileToBeExported.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/FileToBeExported.kt new file mode 100644 index 00000000..63c3d7aa --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/FileToBeExported.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.model + +import io.github.vinceglb.filekit.PlatformFile + +data class FileToBeExported( + val file: PlatformFile, + val exportedFileName: String +) 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 29d6a5ec..ebff7685 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,6 +1,6 @@ package io.github.chrisimx.scanbridge.model -import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings data class HttpClientConfig( val disableCertValidation: Boolean, diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/PositionAndHeight.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/PositionAndHeight.kt new file mode 100644 index 00000000..c0dfc442 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/PositionAndHeight.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.model + +data class PositionAndHeight( + val x: T, + val y: T, + val height: T +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/Rect.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/Rect.kt new file mode 100644 index 00000000..5f82fbda --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/Rect.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.model + +data class Rect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt deleted file mode 100644 index 4fe327c1..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.chrisimx.scanbridge.model - -import kotlinx.io.files.Path - -data class ScanBridgeFile(val path: Path) 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 e73cfb66..74e07959 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,7 +1,7 @@ package io.github.chrisimx.scanbridge.model import io.github.chrisimx.anyscan.CommonScanSettings -import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings import kotlin.uuid.Uuid data class ScanJob( 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 e9101065..e4888959 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanSettingsEnterableData.kt @@ -1,8 +1,8 @@ package io.github.chrisimx.scanbridge.model import io.github.chrisimx.esclkt.ScannerCapabilities -import io.github.chrisimx.scanbridge.PaperFormat -import io.github.chrisimx.scanbridge.loadDefaultFormats +import io.github.chrisimx.scanbridge.paperformat.PaperFormat +import io.github.chrisimx.scanbridge.paperformat.loadDefaultFormats import kotlinx.serialization.Serializable @Serializable diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt index 6d96629f..80760b9b 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScannerHandle.kt @@ -1,8 +1,8 @@ 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.github.chrisimx.scanbridge.protocol.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.protocol.ScanningProtocol import io.ktor.http.Url sealed interface ScannerHandle { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/UrlValidationResult.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/UrlValidationResult.kt new file mode 100644 index 00000000..2d5d2e40 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/UrlValidationResult.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.model + +import io.ktor.http.Url + +sealed class UrlValidationResult { + data class NoError(val url: Url) : UrlValidationResult() + data object Empty : UrlValidationResult() + data object InvalidUrl : UrlValidationResult() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/KoinModulePaperFormat.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/KoinModulePaperFormat.kt new file mode 100644 index 00000000..9e88e78f --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/KoinModulePaperFormat.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.paperformat + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_PAPER_FORMAT = module { + single() bind PaperFormatProvider::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/PaperFormat.kt similarity index 97% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/PaperFormat.kt index 2f6c65fe..380c4000 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormat.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/PaperFormat.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge +package io.github.chrisimx.scanbridge.paperformat import io.github.chrisimx.anyscan.Area import io.github.chrisimx.anyscan.LengthUnit diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/PaperFormatProvider.kt similarity index 88% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/PaperFormatProvider.kt index c01a041d..15ca4809 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/PaperFormatProvider.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/PaperFormatProvider.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge +package io.github.chrisimx.scanbridge.paperformat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt deleted file mode 100644 index e58c298e..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.chrisimx.scanbridge.ports - -import io.github.chrisimx.scanbridge.model.ScanBridgeFile - -interface ZipService { - fun zip(files: List, output: ScanBridgeFile, mapFileName: (ScanBridgeFile) -> String = { it.path.name }) -} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/KoinBasedScanningProtocolManager.kt similarity index 83% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/KoinBasedScanningProtocolManager.kt index 3904c522..a1163bc9 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/KoinBasedScanningProtocolManager.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/KoinBasedScanningProtocolManager.kt @@ -1,10 +1,7 @@ -package io.github.chrisimx.scanbridge.adapters +package io.github.chrisimx.scanbridge.protocol 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 diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/KoinModuleScanningProtocolFeature.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/KoinModuleScanningProtocolFeature.kt new file mode 100644 index 00000000..34e83ef4 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/KoinModuleScanningProtocolFeature.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.protocol + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_SCANNING_PROTOCOL_FEAT = module { + single() bind ScanningProtocolManager::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanProtocolRegistry.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanProtocolRegistry.kt new file mode 100644 index 00000000..30f4c060 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanProtocolRegistry.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.protocol + +import io.github.chrisimx.scanbridge.protocol.escl.EsclScanningProtocol +import io.github.chrisimx.scanbridge.protocol.wsd.WsdScanningProtocol +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_SCAN_PROTOCOLS = module { + single() bind ScanningProtocol::class + single() bind ScanningProtocol::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanningProtocol.kt similarity index 93% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanningProtocol.kt index de30f205..0db84311 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanningProtocol.kt @@ -1,10 +1,11 @@ -package io.github.chrisimx.scanbridge.ports +package io.github.chrisimx.scanbridge.protocol 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 io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -61,7 +62,7 @@ interface ScanningProtocol { fun createScannerHandle(scannerIdentifier: String): ScannerHandle? /** - * Creates a [ScannerDiscoveryBackend] that can be used to find scanners providing + * Creates a [io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend] that can be used to find scanners providing * this scanning protocol. * * If the protocol does not support automatic discovery, this can return null. diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocolManager.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanningProtocolManager.kt similarity index 81% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocolManager.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanningProtocolManager.kt index 089486ac..4f63c3f2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ScanningProtocolManager.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanningProtocolManager.kt @@ -1,6 +1,7 @@ -package io.github.chrisimx.scanbridge.ports +package io.github.chrisimx.scanbridge.protocol import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import kotlinx.coroutines.CoroutineScope interface ScanningProtocolManager { diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScannerDiscoveryBackend.kt similarity index 96% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScannerDiscoveryBackend.kt index 0359bf0c..ba600f39 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScannerDiscoveryBackend.kt @@ -1,13 +1,13 @@ -package io.github.chrisimx.scanbridge.escl +package io.github.chrisimx.scanbridge.protocol.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.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.protocol.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol @@ -49,8 +49,8 @@ class EsclScannerDiscoveryBackend( 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 const val SECURE_SCANNER_DISCOVER_TYPE = "_uscans._tcp.local." + private const val INSECURE_SCANNER_DISCOVER_TYPE = "_uscan._tcp.local." } private val isScannerReachableMap = mutableMapOf>() diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScanningProtocol.kt similarity index 96% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScanningProtocol.kt index 1a773418..c66c3faf 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/escl/EsclScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScanningProtocol.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.escl +package io.github.chrisimx.scanbridge.protocol.escl import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.anyscan.CommonScannerCapabilities @@ -16,12 +16,12 @@ 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.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.protocol.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.protocol.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend -import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.protocol.ScanningProtocol import io.ktor.http.Url import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -214,7 +214,7 @@ class EsclScanningProtocol( scannerCapabilities is ESCLRequestClient.ScannerCapabilitiesResult.Success && scannerCapabilities.scannerCapabilities.makeAndModel == "RICOH" - suspend fun abortIfCancelling(scanJob: io.github.chrisimx.esclkt.ScanJob? = null): Boolean = if (cancelled.value) { + suspend fun abortIfCancelling(scanJob: ScanJob? = null): Boolean = if (cancelled.value) { logger.debug { "Scan job cancelling is set. Aborting, canceling job if possible. scanJob: $scanJob" } scanJob?.cancel() diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/wsd/WsdScannerDiscoveryBackend.kt similarity index 94% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/wsd/WsdScannerDiscoveryBackend.kt index a1b79b23..25cdc1f2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/wsd/WsdScannerDiscoveryBackend.kt @@ -1,8 +1,8 @@ -package io.github.chrisimx.scanbridge.wsd +package io.github.chrisimx.scanbridge.protocol.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.logging.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler import io.github.chrisimx.wsdkt.wsdiscovery.WsScannerServiceDiscovery diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/wsd/WsdScanningProtocol.kt similarity index 94% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/wsd/WsdScanningProtocol.kt index da476498..5d16e47d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/wsd/WsdScanningProtocol.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/wsd/WsdScanningProtocol.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.wsd +package io.github.chrisimx.scanbridge.protocol.wsd import io.github.chrisimx.anyscan.CommonScanSettings import io.github.chrisimx.scanbridge.model.ScanProtocolScannedPage @@ -7,12 +7,12 @@ 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.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.protocol.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.protocol.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings import io.github.chrisimx.scanbridge.ports.ScannerDiscoveryBackend -import io.github.chrisimx.scanbridge.ports.ScanningProtocol +import io.github.chrisimx.scanbridge.protocol.ScanningProtocol import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler import io.github.chrisimx.wsdkt.anyscancompat.toCommonAbstraction import io.github.chrisimx.wsdkt.anyscancompat.toWSDScanTicket diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/KoinModuleLastRouteStore.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/KoinModuleLastRouteStore.kt new file mode 100644 index 00000000..4f735568 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/KoinModuleLastRouteStore.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.savelastroute + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_LAST_ROUTE_STORE = module { + single() bind LastRouteRepository::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/LastRouteRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/LastRouteRepository.kt similarity index 69% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/LastRouteRepository.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/LastRouteRepository.kt index d7027afc..78886c7e 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/LastRouteRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/LastRouteRepository.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge +package io.github.chrisimx.scanbridge.savelastroute interface LastRouteRepository { suspend fun getLastRoute(): String? diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/RoomLastRouteRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/RoomLastRouteRepository.kt similarity index 52% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/RoomLastRouteRepository.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/RoomLastRouteRepository.kt index 413cc57b..e50f5fc7 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories/RoomLastRouteRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/RoomLastRouteRepository.kt @@ -1,9 +1,7 @@ -package io.github.chrisimx.scanbridge.repositories +package io.github.chrisimx.scanbridge.savelastroute -import io.github.chrisimx.scanbridge.LastRouteRepository import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.entities.LastRoute -import io.github.chrisimx.scanbridge.migrations.Migration class RoomLastRouteRepository(db: ScanBridgeDb) : LastRouteRepository { val lastRouteDao = db.lastRouteDao() @@ -17,11 +15,3 @@ class RoomLastRouteRepository(db: ScanBridgeDb) : LastRouteRepository { } } } - -class LastRouteRepoAutoMigration(val a: LastRouteRepository, val b: LastRouteRepository, override val migrationId: String) : Migration { - override suspend fun migrate(db: ScanBridgeDb): Boolean { - val toBeMigrated = a.getLastRoute() - b.setLastRoute(toBeMigrated) - return true - } -} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/KoinModuleLastUsedScanSettingsStore.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/KoinModuleLastUsedScanSettingsStore.kt new file mode 100644 index 00000000..9bde864c --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/KoinModuleLastUsedScanSettingsStore.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.savelastusedscansettings + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_LAST_USED_SCAN_SETTINGS_STORE = module { + single() bind LastUsedScanSettingsRepository::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/CustomScannerRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/CustomScannerRepository.kt similarity index 86% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/CustomScannerRepository.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/CustomScannerRepository.kt index 0718330b..c0182b48 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/CustomScannerRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/CustomScannerRepository.kt @@ -1,8 +1,8 @@ -package io.github.chrisimx.scanbridge.ports +package io.github.chrisimx.scanbridge.scannerdiscovery import io.github.chrisimx.scanbridge.db.entities.CustomScanner -import kotlin.uuid.Uuid import kotlinx.coroutines.flow.Flow +import kotlin.uuid.Uuid interface CustomScannerRepository { fun allFlow(): Flow> diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt index db5417c8..929e9f83 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/DiscoveryUsecase.kt @@ -1,8 +1,8 @@ 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 io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.protocol.ScanningProtocolManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/KoinModuleScannerDiscovery.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/KoinModuleScannerDiscovery.kt new file mode 100644 index 00000000..68c0f72b --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/KoinModuleScannerDiscovery.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.scannerdiscovery + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single +import org.koin.plugin.module.dsl.viewModel + +val KOIN_MODULE_SCANNER_DISCOVERY = module { + single() bind CustomScannerRepository::class + single() + viewModel() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/RoomBackedCustomScannerRepository.kt similarity index 86% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/RoomBackedCustomScannerRepository.kt index d130dfdf..6dfccaa7 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/adapters/RoomBackedCustomScannerRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/RoomBackedCustomScannerRepository.kt @@ -1,10 +1,9 @@ -package io.github.chrisimx.scanbridge.adapters +package io.github.chrisimx.scanbridge.scannerdiscovery 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 +import kotlin.uuid.Uuid class RoomBackedCustomScannerRepository(appDb: ScanBridgeDb) : CustomScannerRepository { private val customScannerDao = appDb.customScannerDao() diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt index 2af43708..58d3f903 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt @@ -5,8 +5,11 @@ 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 io.github.chrisimx.scanbridge.model.EditedCustomScanner +import io.github.chrisimx.scanbridge.model.UrlValidationResult +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.protocol.ScanningProtocolManager +import io.ktor.http.Url import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import kotlinx.coroutines.flow.SharingStarted @@ -17,8 +20,11 @@ data class ProtocolWithExampleHandleString(val protocolIdentifier: String, val e class ScannerDiscoveryScreenViewModel( val customScannerRepo: CustomScannerRepository, val discoveryUsecase: DiscoveryUsecase, - val protocolManager: ScanningProtocolManager + val protocolManager: ScanningProtocolManager, + val loggerFactory: ScanBridgeLoggerFactory ) : ViewModel() { + + private val logger = loggerFactory.withClass(this::class) private val _customScanners = customScannerRepo.allFlow() val customScanners: StateFlow> = _customScanners @@ -39,6 +45,61 @@ class ScannerDiscoveryScreenViewModel( } } + fun validateUrl(urlString: String): UrlValidationResult { + if (urlString.isEmpty()) { + return UrlValidationResult.Empty + } + + return try { + UrlValidationResult.NoError( + Url(urlString) + ) + } catch (_: Exception) { + UrlValidationResult.InvalidUrl + } + } + + data class ScannerCreationResult( + val sessionId: Uuid, + val customScanner: CustomScanner + ) + + fun onCustomScannerCreation( + name: String, + url: String, + protocolIdentifier: String, + save: Boolean, + defaultScannerName: String, + currentlyEditedScanner: EditedCustomScanner + ): ScannerCreationResult? { + val validationResultUrl = validateUrl(url) + if (validationResultUrl !is UrlValidationResult.NoError) { + logger.info { + "Tried to create custom scanner with invalid URL: $validationResultUrl" + } + return null + } + + val url = validationResultUrl.url + val finalName = name.ifEmpty { defaultScannerName } + + val sessionID = Uuid.random() + val uuid = when (currentlyEditedScanner) { + is EditedCustomScanner.EditingOld -> currentlyEditedScanner.scanner.uuid + EditedCustomScanner.New -> Uuid.random() + } + + val newCustomScanner = CustomScanner(uuid, finalName, url, protocolIdentifier) + if (save) { + addScanner(newCustomScanner) + } + + return ScannerCreationResult( + sessionID, + newCustomScanner + ) + } + suspend fun loadScannerByUuid(scanner: Uuid): CustomScanner? = customScannerRepo.getById(scanner) @OptIn(ExperimentalUuidApi::class) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/CommonScanExecutor.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/CommonScanExecutor.kt new file mode 100644 index 00000000..c56295aa --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/CommonScanExecutor.kt @@ -0,0 +1,151 @@ +package io.github.chrisimx.scanbridge.scanning + +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.daos.ScannedPageDao +import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.chrisimx.scanbridge.filesystem.StandardDirectoryProvider +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +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.protocol.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.util.extractPdfImages +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.absolutePath +import io.github.vinceglb.filekit.parent +import io.github.vinceglb.filekit.write +import kotlin.getValue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class CommonScanExecutor( + loggerFactory: ScanBridgeLoggerFactory, + private val scanJobs: ScanJobRepository, + private val db: ScanBridgeDb, + private val httpClientFactory: HttpClientFactory, + private val standardDirectoryProvider: StandardDirectoryProvider +) : ScanExecutor { + private val scannedPageDao: ScannedPageDao = db.scannedPageDao() + + private val logger = loggerFactory.withClass(this::class) + + override suspend fun executeScans() { + try { + scanJobs.setJobRunning(true) + var job = scanJobs.nextJob() + while (job != null) { + doScan(job) + job = scanJobs.nextJob() + } + } finally { + scanJobs.setJobRunning(false) + } + } + + @OptIn(ExperimentalUuidApi::class) + private suspend fun doScan(scanJob: ScanJob) { + scanJobs.notifyStarted(scanJob) + + val scanningProtocol = scanJob.scannerHandle.protocol + val scanningFlow = scanningProtocol.executeScanJob( + scanJob.scannerHandle, + scanJob.connectionSettings, + scanJob.scanSettings, + scanJobs.shouldCancel + ) + + var failed = false + + var pageCounter = 1 + + scanningFlow.collect { processingEvent -> + when (processingEvent) { + ScanJobProcessingEvent.Cancelled -> scanJobs.setCancel(false) + + is ScanJobProcessingEvent.Failure -> { + failed = true + scanJobs.notifyFailed(scanJob, processingEvent.error) + return@collect + } + + is ScanJobProcessingEvent.NewPage -> { + val pageData = processingEvent.scannedPage + + val scanPageFileName = "scan-" + Uuid.random().toString() + + val scanPageFile = PlatformFile(standardDirectoryProvider.appFilesDirectory, scanPageFileName) + + scanPageFile.write(pageData.data) + + when (pageData.contentType) { + // TODO: Extract this conversion to a service + "image/jpeg" -> { + addScan( + scanJob.ownerSessionId, + scanPageFile.absolutePath(), + scanJob.scanSettings, + ScanRelativeRotation.Original, + "scan-${pageCounter.toString().padStart(4, '0')}.jpg" + ) + } + + "application/pdf" -> { + val extractedImages = extractPdfImages( + scanPageFile, + scanPageFile.parent()!! + ) + + extractedImages.forEach { + addScan(scanJob.ownerSessionId, it, scanJob.scanSettings, ScanRelativeRotation.Original) + } + } + + else -> { + failed = true + scanJobs.notifyFailed(scanJob, ScanningError.UnsupportedContentType(pageData.contentType)) + return@collect + } + } + + pageCounter++ + } + } + } + + if (failed) return + + scanJobs.notifyCompleted(scanJob) + } + + suspend fun addScan( + sessionID: Uuid, + path: String, + settings: CommonScanSettings, + rotation: ScanRelativeRotation, + fileName: String? = null + ) { + logger.debug { "Adding scan: $path, $rotation" } + db.useWriterConnection { + it.immediateTransaction { + val highestIdx = scannedPageDao.getHighestIdxForSession(sessionID) ?: -1 + + logger.debug { "Inserting scan with index ${highestIdx + 1}" } + scannedPageDao.insertAll( + ScannedPage( + Uuid.generateV4(), + sessionID, + path, + settings, + rotation, + highestIdx + 1, + fileName + ) + ) + } + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteScanUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteScanUseCase.kt new file mode 100644 index 00000000..085de712 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteScanUseCase.kt @@ -0,0 +1,18 @@ +package io.github.chrisimx.scanbridge.scanning + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.delete + +class DeleteScanUseCase( + scanBridgeDb: ScanBridgeDb +) { + private val scannedPageDao = scanBridgeDb.scannedPageDao() + suspend fun deleteScan(scan: ScannedPage) { + scannedPageDao.delete(scan) + + val scannedPageFile = PlatformFile(scan.filePath) + scannedPageFile.delete() + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteSessionUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteSessionUseCase.kt new file mode 100644 index 00000000..c1d5efb2 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteSessionUseCase.kt @@ -0,0 +1,45 @@ +package io.github.chrisimx.scanbridge.scanning + +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.delete +import kotlin.uuid.Uuid +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext + +class DeleteSessionUseCase( + val db: ScanBridgeDb +) { + val sessionDao = db.sessionDao() + val scannedPageDao = db.scannedPageDao() + val tmpFileDao = db.tmpFileDao() + + suspend fun deleteSession(sessionId: Uuid) { + val filePaths = mutableListOf() + val tmpPaths = mutableListOf() + + db.useWriterConnection { + it.immediateTransaction { + val scannedPages = scannedPageDao.getAllForSession(sessionId) + val tmpFiles = tmpFileDao.getFilesBySessionId(sessionId) + + filePaths += scannedPages.map { PlatformFile(it.filePath) } + tmpPaths += tmpFiles.map { PlatformFile(it.path) } + + sessionDao.deleteById(sessionId) + } + } + + withContext(Dispatchers.IO) { + filePaths.forEach { + it.delete(mustExist = false) + } + tmpPaths.forEach { + it.delete(mustExist = false) + } + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt new file mode 100644 index 00000000..514679be --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt @@ -0,0 +1,49 @@ +package io.github.chrisimx.scanbridge.scanning + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.TempFile +import io.github.chrisimx.scanbridge.export.ExportModuleManager +import io.github.chrisimx.scanbridge.export.ExportModuleType +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.path +import kotlin.uuid.Uuid +import kotlinx.coroutines.CancellationException + + +class ExportAllPagesUseCase( + val exportModuleManager: ExportModuleManager, + scanBridgeDb: ScanBridgeDb +) { + val scannedPageDao = scanBridgeDb.scannedPageDao() + val tmpFileDao = scanBridgeDb.tmpFileDao() + + suspend operator fun invoke(exportModuleType: ExportModuleType, sessionId: Uuid): ExportAllPagesResult { + val exportModule = exportModuleManager.getExportModuleByType(exportModuleType) + ?: return ExportAllPagesResult.ExportModuleNotFound + + val scannedPages = scannedPageDao.getAllForSession(sessionId) + + if (scannedPages.isEmpty()) { + return ExportAllPagesResult.NoPages + } + + return try { + val exportFile = exportModule.export(scannedPages) + tmpFileDao.insertAll( + TempFile(ownerSessionId = sessionId, path = exportFile.path) + ) + ExportAllPagesResult.Success(exportFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ExportAllPagesResult.ExportFailed(e) + } + } +} + +sealed class ExportAllPagesResult { + data class Success(val exportedFile: PlatformFile) : ExportAllPagesResult() + data class ExportFailed(val error: Throwable) : ExportAllPagesResult() + data object ExportModuleNotFound : ExportAllPagesResult() + data object NoPages : ExportAllPagesResult() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanJobManagement.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanJobManagement.kt new file mode 100644 index 00000000..e48b0f67 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanJobManagement.kt @@ -0,0 +1,10 @@ +package io.github.chrisimx.scanbridge.scanning + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_SCAN_JOB_MANAGEMENT = module { + single() + single() bind ScanExecutor::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningScreen.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningScreen.kt new file mode 100644 index 00000000..a6abd1ac --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningScreen.kt @@ -0,0 +1,11 @@ +package io.github.chrisimx.scanbridge.scanning + +import io.github.chrisimx.scanbridge.ScanSettingsComposableStateHolder +import org.koin.dsl.module +import org.koin.plugin.module.dsl.factory +import org.koin.plugin.module.dsl.viewModel + +val KOIN_MODULE_SCANNING_SCREEN = module { + factory() + viewModel() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningUseCases.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningUseCases.kt new file mode 100644 index 00000000..da06c8d9 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningUseCases.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge.scanning + +import io.github.chrisimx.scanbridge.imagerotation.RotateScanUseCase +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_SCANNING_USE_CASES = module { + single() + single() + single() + single() + single() + single() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutionEntryPoint.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutionEntryPoint.kt new file mode 100644 index 00000000..b92d1e97 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutionEntryPoint.kt @@ -0,0 +1,5 @@ +package io.github.chrisimx.scanbridge.scanning + +interface ScanExecutionEntryPoint { + fun start() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutor.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutor.kt new file mode 100644 index 00000000..37c2ffd0 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutor.kt @@ -0,0 +1,5 @@ +package io.github.chrisimx.scanbridge.scanning + +interface ScanExecutor { + suspend fun executeScans() +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanJobRepository.kt similarity index 82% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanJobRepository.kt index 2abc492e..175383b1 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanJobRepository.kt @@ -1,9 +1,11 @@ -package io.github.chrisimx.scanbridge.services +package io.github.chrisimx.scanbridge.scanning import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanningError +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow @@ -14,7 +16,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber sealed class ScanJobEvent { data class Completed(val job: ScanJob) : ScanJobEvent() @@ -22,7 +23,11 @@ sealed class ScanJobEvent { data class Started(val job: ScanJob) : ScanJobEvent() } -class ScanJobRepository { +class ScanJobRepository( + val loggerFactory: ScanBridgeLoggerFactory +) { + val logger = loggerFactory.withClass(this::class) + // Channel acts as an async queue private val jobChannel = Channel(capacity = Channel.UNLIMITED) @@ -49,19 +54,19 @@ class ScanJobRepository { fun enqueue(job: ScanJob) { coroutineScope.launch { jobChannel.send(job) - Timber.d("Enqueued job: $job") + logger.debug { "Enqueued job: $job" } } } fun setJobRunning(value: Boolean) { - Timber.d("setJobRunning($value)") + logger.debug { "setJobRunning($value)" } _isJobRunning.update { value } } fun setCancel(value: Boolean) { - Timber.d("setCancel($value)") + logger.debug { "setCancel($value)" } _shouldCancel.update { value } @@ -71,7 +76,7 @@ class ScanJobRepository { * Notify that a job was completed */ fun notifyCompleted(job: ScanJob) { - Timber.d("notifyCompleted($job)") + logger.debug { "notifyCompleted($job)" } coroutineScope.launch { _events.emit(ScanJobEvent.Completed(job)) } @@ -81,7 +86,7 @@ class ScanJobRepository { * Notify that a job was started */ fun notifyStarted(job: ScanJob) { - Timber.d("notifyStarted($job)") + logger.debug { "notifyStarted($job)" } coroutineScope.launch { _events.emit(ScanJobEvent.Started(job)) } @@ -91,12 +96,11 @@ class ScanJobRepository { * Notify that a job failed */ fun notifyFailed(job: ScanJob, error: ScanningError) { - Timber.d("notifyFailed($job, $error)") + logger.debug { "notifyFailed($job, $error)" } coroutineScope.launch { _events.emit(ScanJobEvent.Failed(job, error)) } } - /** * Consume the next job */ diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanningScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanningScreenViewModel.kt new file mode 100644 index 00000000..ab5d869d --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanningScreenViewModel.kt @@ -0,0 +1,535 @@ +/* + * 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.scanning + +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +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.anyscan.CommonScanSettings +import io.github.chrisimx.anyscan.CommonScanSettingsEditor +import io.github.chrisimx.anyscan.CommonScannerCapabilities +import io.github.chrisimx.anyscan.ScanSettingsMap +import io.github.chrisimx.scanbridge.ScanSettingsComposableStateHolder +import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings +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.export.ExportCapabilitiesProvider +import io.github.chrisimx.scanbridge.export.ExportModuleManager +import io.github.chrisimx.scanbridge.export.ExportModuleType +import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider +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.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.protocol.ScannerCapabilitiesResult +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.savelastusedscansettings.LastUsedScanSettingsRepository +import io.github.chrisimx.scanbridge.imagerotation.RotateScanUseCase +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.copyTo +import io.github.vinceglb.filekit.path +import kotlin.uuid.Uuid +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.koin.core.annotation.InjectedParam +import org.koin.core.scope.Scope + +enum class ScanningScreenEvent { + SCAN_FINISHED, + SCAN_STARTED +} + +class ScanningScreenViewModel( + @InjectedParam + val scannerHandle: ScannerHandle, + @InjectedParam + val timeout: UInt, + @InjectedParam + val withDebugInterceptor: Boolean, + @InjectedParam + val certificateValidationDisabled: Boolean, + @InjectedParam + val sessionID: Uuid, + val db: ScanBridgeDb, + val scanJobRepo: ScanJobRepository, + val appSettingsRepository: AppSettingsRepository, + val lastUsedScanSettingsRepo: LastUsedScanSettingsRepository, + val initialScanSettingsProvider: InitialScanSettingsProvider, + val startScanUseCase: StartScanUseCase, + val koinScope: Scope, + val loggerFactory: ScanBridgeLoggerFactory, + val exportModuleManager: ExportModuleManager, + exportCapabilitiesProvider: ExportCapabilitiesProvider, + val deleteScanUseCase: DeleteScanUseCase, + val deleteSessionUseCase: DeleteSessionUseCase, + val rotatePageUseCase: RotateScanUseCase, + val swapPagesUseCase: SwapPagesUseCase, + val exportAllPagesUseCase: ExportAllPagesUseCase +) : ViewModel() { + private val logger = loggerFactory.withClass(this::class) + + private val tmpFileDao = db.tmpFileDao() + private val scannedPageDao = db.scannedPageDao() + private val sessionDao = db.sessionDao() + + private val _confirmLeaveDialogShown = MutableStateFlow(false) + val confirmLeaveDialogShown: StateFlow = _confirmLeaveDialogShown.asStateFlow() + + private val _confirmPageDeleteDialogShown = MutableStateFlow(false) + val confirmPageDeleteDialogShown: StateFlow = _confirmPageDeleteDialogShown.asStateFlow() + + private val _fatalError = MutableStateFlow(null) + val fatalError: StateFlow = _fatalError.asStateFlow() + + private val _scanSettingsVM = MutableStateFlow(null) + val scanSettingsVM: StateFlow = _scanSettingsVM.asStateFlow() + + private val _capabilities = MutableStateFlow(null) + val capabilities: StateFlow = _capabilities.asStateFlow() + + private val _scanSettingsMenuOpen = MutableStateFlow(false) + val scanSettingsMenuOpen: StateFlow = _scanSettingsMenuOpen.asStateFlow() + + private val _showExportOptions = MutableStateFlow(false) + val showExportOptions: StateFlow = _showExportOptions.asStateFlow() + + private val _showSaveOptions = MutableStateFlow(false) + val showSaveOptions: StateFlow = _showSaveOptions.asStateFlow() + + private val _isRotating = MutableStateFlow(false) + val isRotating: StateFlow = _isRotating.asStateFlow() + + private val _isExporting = MutableStateFlow(false) + val isExporting: StateFlow = _isExporting.asStateFlow() + + val tempFiles: StateFlow> = tmpFileDao.getFilesFlowBySessionId(sessionID) + .onEach { logger.debug { "Temp files changed: $it" } } + .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) + + val scannedPages: StateFlow> = scannedPageDao.getAllForSessionFlow(sessionID) + .onEach { logger.debug { "Scanned pages changed: $it" } } + .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) + + val session: StateFlow = sessionDao.getSessionFlowById(sessionID) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val availableExportModules = exportModuleManager.getAllExportModulesFlow() + .map { exportModuleList -> exportModuleList.map { it.type } } + .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) + + val readyForScans = combine(scanSettingsVM, capabilities) { scanSettingsVM, capabilities -> + scanSettingsVM != null && capabilities != null + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isScanJobRunning = scanJobRepo.isJobRunning + val isScanJobCancelling = scanJobRepo.shouldCancel + + val currentPageIdx = session.map { + it?.currentPage ?: 0 + }.stateIn(viewModelScope, SharingStarted.Eagerly, 0) + + val currentPage = combine(currentPageIdx, scannedPages) { pageIdx, pages -> + pages.getOrNull(pageIdx) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val saveSupported = FileSaveType.Save in exportCapabilitiesProvider.supportedFileSaveTypes + val shareSupported = FileSaveType.Share in exportCapabilitiesProvider.supportedFileSaveTypes + + private val _errorStream = MutableSharedFlow() + val errorStream = _errorStream.asSharedFlow() + + private val _exportQueue = Channel( + capacity = Channel.BUFFERED + ) + + val exportQueue = _exportQueue.receiveAsFlow() + + private suspend fun addTempFile(file: PlatformFile) { + val tmpFile = TempFile(Uuid.generateV4(), sessionID, file.path) + tmpFileDao.insertAll(tmpFile) + } + + init { + retrieveScannerCapabilities() + } + + suspend fun getPageIdx(): Int = sessionDao.getSessionById(sessionID)?.currentPage ?: 0 + + fun setCancelling(value: Boolean) { + scanJobRepo.setCancel(value) + } + + init { + session + .filter { it?.currentScanSettings != null && it.currentSettingsUIData != null } + .map { + LastUsedScanSettings( + lastUsedScanSettings = it!!.currentScanSettings!!, + lastUsedScanSettingsEnterable = it.currentSettingsUIData!! + ) + } + .distinctUntilChanged() + .onEach { newLastUsedScanSettings -> + lastUsedScanSettingsRepo.setLastUsedScanSettings( + newLastUsedScanSettings + ) + } + .launchIn(viewModelScope.coroutineScope) + } + + fun setPageIdx(idx: Int) { + viewModelScope.launch { + sessionDao.updateCurrentPage(sessionID, idx) + } + } + + fun setShowExportOptionsPopup(show: Boolean) { + _showExportOptions.value = show + } + + fun setShowSaveOptionsPopup(show: Boolean) { + _showSaveOptions.value = show + } + + fun setScanSettingsMenuOpen(value: Boolean) { + _scanSettingsMenuOpen.value = value + } + + + private fun setFatalError(errorType: FatalScanningScreenError?, error: String?) { + _fatalError.value = FatalErrorDescription(errorType, error) + } + + fun rotatePage(pageId: Uuid) { + viewModelScope.launch { + if (!_isRotating.compareAndSet(expect = false, update = true)) { + return@launch + } + + try { + rotatePageUseCase.rotateScan(pageId) + } finally { + _isRotating.update { false } + } + } + } + + fun onSaveLocationSelected(exportEvent: ExportEvent, platformFile: PlatformFile?) { + if (platformFile == null) { + return + } + + viewModelScope.launch { + copyExportToPlatformFile(exportEvent, platformFile) + } + } + + private fun copyExportToPlatformFile(exportEvent: ExportEvent, destination: PlatformFile) = viewModelScope.launch { + val source = exportEvent.exportedFile + withContext(Dispatchers.IO) { + source.copyTo(destination) + } + } + + suspend fun saveUpdatedScanSettingsUiData(newData: ScanSettingsEnterableDataV1?) { + logger.debug { "Settings ui data updated $newData" } + sessionDao.updateScanSettingsUiData(sessionID, newData) + } + + suspend fun setScannerCapabilities(caps: CommonScannerCapabilities) { + _capabilities.value = caps + val storedSession = sessionDao.getSessionById(sessionID) + + logger.debug { "Stored session: $storedSession" } + + val updateSettings: suspend (CommonScanSettingsEditor.() -> Unit) -> Unit = { edit -> + db.useWriterConnection { + it.immediateTransaction { + val oldSession = sessionDao.getSessionById(sessionID) + ?: return@immediateTransaction + + val oldSettings = oldSession.currentScanSettings + ?: CommonScanSettings( + setting = ScanSettingsMap.empty() + ) + + val editor = CommonScanSettingsEditor( + capabilities = caps, + initial = oldSettings + ) + + editor.edit() + + val newSettings = editor.build() + + val newSession = oldSession.copy( + currentScanSettings = newSettings + ) + + logger.debug {"Settings updated $newSettings" } + + sessionDao.update(newSession) + } + } + } + + val localScanSettingsVM: ScanSettingsComposableStateHolder + + if (storedSession != null) { + localScanSettingsVM = ScanSettingsComposableStateHolder( + MutableStateFlow(caps).asStateFlow(), + session.map { it?.currentScanSettings ?: storedSession.currentScanSettings!! } + .stateIn( + viewModelScope, + SharingStarted.Lazily, + storedSession.currentScanSettings!! + ), + storedSession.currentSettingsUIData?.copy(), + updateSettings, + null, + viewModelScope.coroutineScope, + koinScope.get(), + koinScope.get(), + koinScope.get(), + koinScope.get() + ) + + _scanSettingsVM.value = localScanSettingsVM + } else { + // Try to load saved scan settings first, fallback to defaults if none exist + val savedSettings = lastUsedScanSettingsRepo.getLastUsedScanSettings() + + val lastUsedScanSettings = savedSettings?.lastUsedScanSettings + val lastUsedScanSettingsEnterable = savedSettings?.lastUsedScanSettingsEnterable + + val initialSettings = if (lastUsedScanSettings != null) { + val editor = CommonScanSettingsEditor( + caps, + lastUsedScanSettings + ) + editor.build() + } else { + val editor = CommonScanSettingsEditor( + caps, + CommonScanSettings(setting = ScanSettingsMap.empty()) + ) + initialScanSettingsProvider.applyDefaults(editor, caps) + editor.build() + } + + sessionDao.insertAll( + Session( + sessionID, + initialSettings, + lastUsedScanSettingsEnterable + ) + ) + + localScanSettingsVM = ScanSettingsComposableStateHolder( + MutableStateFlow(caps).asStateFlow(), + session.map { it?.currentScanSettings ?: initialSettings } + .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), + lastUsedScanSettingsEnterable, + updateSettings, + { initialSettings }, + viewModelScope.coroutineScope, + koinScope.get(), + koinScope.get(), + koinScope.get(), + koinScope.get() + ) + + _scanSettingsVM.value = localScanSettingsVM + } + + // Subscribe to scan settings ui data changes so that we can save them to the database + localScanSettingsVM.uiState.onEach { + saveUpdatedScanSettingsUiData(it) + }.launchIn(viewModelScope.coroutineScope) + } + + fun swapTwoPages(page1: ScannedPage, page2: ScannedPage) { + viewModelScope.launch { + swapPagesUseCase(page1, page2) + } + } + + fun pageDeletionRequested() { + _confirmPageDeleteDialogShown.value = true + } + + fun onConfirmPageDeletion(page: ScannedPage) { + viewModelScope.launch { + try { + deleteScanUseCase.deleteScan(page) + } catch (e: Exception) { + sendErrorToUI(ScanningScreenError.DeletionError(e)) + } finally { + _confirmPageDeleteDialogShown.value = false + } + } + } + + fun onConfirmPageDeletionDismissed() { + _confirmPageDeleteDialogShown.value = false + } + + fun leaveRequested() { + _confirmLeaveDialogShown.value = true + } + + fun onLeaveDismissed() { + _confirmLeaveDialogShown.value = false + } + + fun onLeaveConfirmed(onFinish: () -> Unit) { + viewModelScope.launch { + deleteSessionUseCase.deleteSession(sessionID) + _confirmLeaveDialogShown.value = false + onFinish() + } + } + + private suspend fun sendErrorToUI(error: ScanningScreenError) { + _errorStream.emit(error) + } + + fun exportAllPages(exportModuleType: ExportModuleType, saveType: FileSaveType) = viewModelScope.launch { + if (!_isExporting.compareAndSet(expect = false, update = true)) { + return@launch + } + + try { + when (val result = exportAllPagesUseCase(exportModuleType, sessionID)) { + ExportAllPagesResult.ExportModuleNotFound -> { + sendErrorToUI(ScanningScreenError.ExportModuleNotFound) + } + + ExportAllPagesResult.NoPages -> { + sendErrorToUI(ScanningScreenError.NoPagesScannedYet) + } + + is ExportAllPagesResult.ExportFailed -> { + sendErrorToUI(ScanningScreenError.ExportError(result.error)) + } + + is ExportAllPagesResult.Success -> { + _exportQueue.send( + ExportEvent(result.exportedFile, saveType) + ) + } + } + } catch (e: Exception) { + sendErrorToUI(ScanningScreenError.ExportError(e)) + } finally { + _isExporting.update { false } + } + } + + fun scan() { + viewModelScope.launch { + val currentSettings = session.value?.currentScanSettings + + if (currentSettings == null) { + logger.error { "Could not start scan job. Current scan settings null" } + return@launch + } + + if (isScanJobRunning.value) { + logger.error { "Job still running" } + sendErrorToUI(ScanningScreenError.JobStillRunning) + return@launch + } + + startScanUseCase.startScan( + ownerSessionId = sessionID, + scannerHandle = scannerHandle, + scanSettings = currentSettings, + connectionSettings = scannerConnectionSettings() + ) + } + } + + fun scannerConnectionSettings() = ScannerConnectionSettings( + timeout.toULong(), + timeout.toULong(), + certificateValidationDisabled, + withDebugInterceptor + ) + + fun retrieveScannerCapabilities() = viewModelScope.launch { + val connectionSettings = scannerConnectionSettings() + + val scannerCapabilities = scannerHandle.scannerCapabilities(connectionSettings) + + if (scannerCapabilities !is ScannerCapabilitiesResult.Success) { + logger.error { "Error while retrieving ScannerCapabilities: $scannerCapabilities" } + setFatalError(FatalScanningScreenError.ScannerCapsRetrieval, "$scannerCapabilities") + return@launch + } + + setScannerCapabilities(scannerCapabilities.scannerCapabilities) + } +} + +sealed class ScanningScreenError { + object JobStillRunning : ScanningScreenError() + object ExportModuleNotFound : ScanningScreenError() + object NoPagesScannedYet : ScanningScreenError() + class ExportError(val error: Throwable) : ScanningScreenError() + + class DeletionError(val error: Throwable) : ScanningScreenError() +} + + +enum class FatalScanningScreenError { + ScannerCapsRetrieval, +} + +data class FatalErrorDescription(val errorType: FatalScanningScreenError?, val text: String?) + +enum class FileSaveType { + Share, + Save +} + +data class ExportEvent(val exportedFile: PlatformFile, val saveType: FileSaveType, val exportId: Uuid = Uuid.generateV4()) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt new file mode 100644 index 00000000..c7f98d99 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt @@ -0,0 +1,51 @@ +package io.github.chrisimx.scanbridge.scanning + +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.model.ScanJob +import io.github.chrisimx.scanbridge.model.ScannerHandle +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings +import kotlin.uuid.Uuid + +class StartScanUseCase( + val scanJobRepo: ScanJobRepository, + val scanExecutionEntryPoint: ScanExecutionEntryPoint +) { + fun startScan( + ownerSessionId: Uuid, + scannerHandle: ScannerHandle, + scanSettings: CommonScanSettings, + connectionSettings: ScannerConnectionSettings + ) { + val scanJob = ScanJob( + Uuid.generateV4(), + ownerSessionId, + scanSettings.applyFileFormat(), + scannerHandle, + connectionSettings + ) + + scanJobRepo.enqueue(scanJob) + scanExecutionEntryPoint.start() + } + + /** + * 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 { + val isBlackAndWhite = this.setting[ScannerConcept.ColorMode] + ?.value == ColorMode.BlackAndWhite1.toString() + + val format = if (isBlackAndWhite) FileFormat.PDF else FileFormat.JPEG + + return this.copy( + format = format.asEnumOrRaw() + ) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/SwapPagesUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/SwapPagesUseCase.kt new file mode 100644 index 00000000..2c571e5c --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/SwapPagesUseCase.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge.scanning + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.ScannedPage + +class SwapPagesUseCase( + val scanBridgeDb: ScanBridgeDb +) { + val scannedPageDao = scanBridgeDb.scannedPageDao() + + suspend operator fun invoke(page1: ScannedPage, page2: ScannedPage) { + scannedPageDao.swapPages(page1, page2) + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/startupmessages/KoinModuleStartupMessages.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/startupmessages/KoinModuleStartupMessages.kt new file mode 100644 index 00000000..6772aaa3 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/startupmessages/KoinModuleStartupMessages.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.startupmessages + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_STARTUP_MESSAGES = module { + single() bind ShownStartupMessagesRepository::class +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt deleted file mode 100644 index 482aba0b..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -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/util/PdfUtil.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.kt new file mode 100644 index 00000000..c611f939 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.kt @@ -0,0 +1,6 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.vinceglb.filekit.PlatformFile +import kotlin.uuid.Uuid + +expect fun extractPdfImages(pdf: PlatformFile, outputDir: PlatformFile): List diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ResultUtil.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ResultUtil.kt new file mode 100644 index 00000000..e31f2ee1 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ResultUtil.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.util + +import kotlinx.coroutines.CancellationException + +fun Result.getOrNullIgnoreCancellation(): T? { + onFailure { + if (it is CancellationException) { + throw it + } + } + return getOrNull() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt new file mode 100644 index 00000000..af32c6cd --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt @@ -0,0 +1,17 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.div +import io.github.vinceglb.filekit.name +import io.github.vinceglb.filekit.parent +import kotlin.time.Clock + +fun PlatformFile.getEditedFile(): PlatformFile? { + val name = this.name + val baseName = name.substringBefore(" edit-") + val editName = "$baseName edit-${Clock.System.now().toEpochMilliseconds()}" + + return this.parent()?.let { + PlatformFile(it, editName) + } +} diff --git a/core/src/commonTest/kotlin/PaperFormatTest.kt b/core/src/commonTest/kotlin/PaperFormatTest.kt index 24d98aeb..c42c2ce0 100644 --- a/core/src/commonTest/kotlin/PaperFormatTest.kt +++ b/core/src/commonTest/kotlin/PaperFormatTest.kt @@ -17,7 +17,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import io.github.chrisimx.scanbridge.loadDefaultFormats +import io.github.chrisimx.scanbridge.paperformat.loadDefaultFormats import kotlin.test.Test import kotlin.test.assertEquals diff --git a/core/src/androidMain/kotlin/InetAddressKMPConverter.kt b/core/src/jvmAndAndroid/kotlin/InetAddressKMPConverter.kt similarity index 100% rename from core/src/androidMain/kotlin/InetAddressKMPConverter.kt rename to core/src/jvmAndAndroid/kotlin/InetAddressKMPConverter.kt diff --git a/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt b/core/src/jvmAndAndroid/kotlin/JvmHttpClientFactory.kt similarity index 85% rename from core/src/androidMain/kotlin/AndroidHttpClientFactory.kt rename to core/src/jvmAndAndroid/kotlin/JvmHttpClientFactory.kt index 15586c79..bf40c30d 100644 --- a/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt +++ b/core/src/jvmAndAndroid/kotlin/JvmHttpClientFactory.kt @@ -1,14 +1,14 @@ +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.model.HttpClientConfig import io.github.chrisimx.scanbridge.ports.HttpClientFactory -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging -class AndroidHttpClientFactory(loggerFactory: ScanBridgeLoggerFactory) : HttpClientFactory { - val httpClientLogger = loggerFactory.withTag("AndroidHttpClient") +class JvmHttpClientFactory(loggerFactory: ScanBridgeLoggerFactory) : HttpClientFactory { + val httpClientLogger = loggerFactory.withTag("JvmHttpClient") override fun create(config: HttpClientConfig): HttpClient = HttpClient(OkHttp) { install(HttpTimeout) { diff --git a/core/src/jvmAndAndroid/kotlin/JvmImageCropService.kt b/core/src/jvmAndAndroid/kotlin/JvmImageCropService.kt new file mode 100644 index 00000000..a70c5d8f --- /dev/null +++ b/core/src/jvmAndAndroid/kotlin/JvmImageCropService.kt @@ -0,0 +1,50 @@ +import io.github.chrisimx.scanbridge.cropfeature.ImageCropService +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.model.Rect +import io.github.chrisimx.scanbridge.util.getOrNullIgnoreCancellation +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.sink +import io.github.vinceglb.filekit.source +import javax.imageio.ImageIO +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.asInputStream +import kotlinx.io.asOutputStream +import kotlinx.io.buffered + +class JvmImageCropService( + loggerFactory: ScanBridgeLoggerFactory +) : ImageCropService { + private val logger = loggerFactory.withClass(this::class) + + override suspend fun crop(sourceFile: PlatformFile, outputFile: PlatformFile, cropRect: Rect): Boolean = withContext(Dispatchers.IO) { + val sourceBitmap = runCatching { + ImageIO.read(sourceFile.file) + }.getOrNullIgnoreCancellation() + + if (sourceBitmap == null) { + logger.error { + "Could not decode source bitmap for cropping" + } + return@withContext false + } + + val startX = cropRect.left * sourceBitmap.width + val startY = cropRect.top * sourceBitmap.height + + val width = (cropRect.right - cropRect.left) * sourceBitmap.width + val height = (cropRect.bottom - cropRect.top) * sourceBitmap.height + + val croppedImage = sourceBitmap.getSubimage( + startX.roundToInt(), + startY.roundToInt(), + width.roundToInt(), + height.roundToInt() + ) + + ImageIO.write(croppedImage, "jpg", outputFile.file) + + return@withContext true + } +} diff --git a/core/src/jvmAndAndroid/kotlin/KoinModuleJvmAndAndroidPlatform.kt b/core/src/jvmAndAndroid/kotlin/KoinModuleJvmAndAndroidPlatform.kt new file mode 100644 index 00000000..b8cea08a --- /dev/null +++ b/core/src/jvmAndAndroid/kotlin/KoinModuleJvmAndAndroidPlatform.kt @@ -0,0 +1,14 @@ +import io.github.chrisimx.localization.JvmLocaleProvider +import io.github.chrisimx.localization.JvmNumberFormatter +import io.github.chrisimx.scanbridge.localization.LocaleProvider +import io.github.chrisimx.scanbridge.localization.NumberFormatter +import io.github.chrisimx.scanbridge.ports.HttpClientFactory +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_JVM_AND_ANDROID_PLATFORM = module { + single() bind HttpClientFactory::class + single() bind LocaleProvider::class + single() bind NumberFormatter::class +} diff --git a/core/src/androidMain/kotlin/TrustAllTrustManager.kt b/core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt similarity index 78% rename from core/src/androidMain/kotlin/TrustAllTrustManager.kt rename to core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt index 8d4abffe..6a86e476 100644 --- a/core/src/androidMain/kotlin/TrustAllTrustManager.kt +++ b/core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt @@ -1,4 +1,4 @@ -import android.annotation.SuppressLint + import java.security.SecureRandom import java.security.cert.X509Certificate import javax.net.ssl.SSLContext @@ -6,12 +6,9 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager -@SuppressLint("CustomX509TrustManager") object TrustAllTM : X509TrustManager { - @SuppressLint("TrustAllX509TrustManager") override fun checkClientTrusted(chain: Array, authType: String) {} - @SuppressLint("TrustAllX509TrustManager") override fun checkServerTrusted(chain: Array, authType: String) {} override fun getAcceptedIssuers(): Array = arrayOf() } @@ -21,7 +18,7 @@ fun getTrustAllTM(): Pair { TrustAllTM ) - val sslContext = SSLContext.getInstance("SSL").apply { + val sslContext = SSLContext.getInstance("TLS").apply { init(null, trustAllCerts, SecureRandom()) } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt b/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/localization/JvmLocaleProvider.kt similarity index 56% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt rename to core/src/jvmAndAndroid/kotlin/io/github/chrisimx/localization/JvmLocaleProvider.kt index 7b262da6..d773d18a 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt +++ b/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/localization/JvmLocaleProvider.kt @@ -1,22 +1,24 @@ -package io.github.chrisimx.scanbridge.services +package io.github.chrisimx.localization -import android.annotation.SuppressLint +import io.github.chrisimx.scanbridge.localization.LocaleProvider +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.model.Locale -import io.github.chrisimx.scanbridge.ports.LocaleProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import timber.log.Timber -class AndroidLocaleProvider : LocaleProvider { - @SuppressLint("ConstantLocale") // Constant local is not correct because update is called on Activity recreation +class JvmLocaleProvider( + loggerFactory: ScanBridgeLoggerFactory +) : LocaleProvider { + + private val logger = loggerFactory.withClass(this::class) private val _locale = MutableStateFlow(getCurrentLocale()) override val locale: StateFlow = _locale.asStateFlow() private fun getCurrentLocale(): Locale = Locale(java.util.Locale.getDefault().country) internal fun update() { - Timber.d("Locale updated to $locale") + logger.debug { "Locale updated to $locale" } _locale.value = getCurrentLocale() } } diff --git a/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.jvmAndAndroid.kt b/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.jvmAndAndroid.kt new file mode 100644 index 00000000..5467abac --- /dev/null +++ b/core/src/jvmAndAndroid/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.jvmAndAndroid.kt @@ -0,0 +1,61 @@ +package io.github.chrisimx.scanbridge.util + +import com.itextpdf.kernel.pdf.PdfDocument +import com.itextpdf.kernel.pdf.PdfName +import com.itextpdf.kernel.pdf.PdfReader +import com.itextpdf.kernel.pdf.xobject.PdfImageXObject +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.absolutePath +import javax.imageio.ImageIO +import kotlin.text.iterator +import kotlin.use +import kotlin.uuid.Uuid +import kotlinx.io.Buffer +import kotlinx.io.asInputStream +import kotlinx.io.write + +actual fun extractPdfImages( + pdf: PlatformFile, + outputDir: PlatformFile +): List { + val pdfDoc = PdfDocument(PdfReader(pdf.file)) + var imageCounter = 1 + + val savedImages = mutableListOf() + + for (i in 1..pdfDoc.numberOfPages) { + val page = pdfDoc.getPage(i) + val xObjects = page.resources.getResource(PdfName.XObject) ?: continue + + for (key in xObjects.keySet()) { + val obj = xObjects.getAsStream(key) ?: continue + val subtype = obj.getAsName(PdfName.Subtype) + if (subtype == PdfName.Image) { + val imageXObject = PdfImageXObject(obj) + val bytes = imageXObject.imageBytes + + val imageByteBuffer = Buffer() + imageByteBuffer.write(bytes) + + // Convert raw bytes to Bitmap + val image = runCatching { + imageByteBuffer.asInputStream().use { + ImageIO.read(it) + } + }.getOrNullIgnoreCancellation() + + if (image != null) { + val outputFile = PlatformFile(outputDir, "scan-${Uuid.random()}.png") + // Save bitmap as PNG + ImageIO.write(image, "png", outputFile.file) + savedImages.add(outputFile.absolutePath()) + imageCounter++ + } + } + } + } + + pdfDoc.close() + + return savedImages +} diff --git a/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt b/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt new file mode 100644 index 00000000..11153065 --- /dev/null +++ b/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt @@ -0,0 +1,93 @@ +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.model.MdnsService +import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService +import java.util.concurrent.atomic.AtomicBoolean +import javax.jmdns.JmDNS +import javax.jmdns.ServiceEvent +import javax.jmdns.ServiceInfo +import javax.jmdns.ServiceListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class JvmMdnsDiscoverService( + loggerFactory: ScanBridgeLoggerFactory +) : MdnsDiscoverService { + val logger = loggerFactory.withClass(this::class) + + var _serviceType: String? = null + override val serviceType: String? + get() = _serviceType + + val _foundServices = MutableStateFlow(emptyMap()) + + override val foundServices: StateFlow> = _foundServices.asStateFlow() + private val started = AtomicBoolean(false) + val jmdns = JmDNS.create() + + private fun ServiceInfo.getTxtRecords(): Map { + val propertyNames = this.propertyNames + return propertyNames + .asSequence() + .associateWith { + this.getPropertyBytes(it) + } + } + + override fun start(serviceType: String) { + if (!started.compareAndSet(false, true)) { + logger.debug { "JvmMdnsDiscoverService already started" } + return + } + logger.debug { "Starting JvmMdnsDiscoverService with $serviceType" } + + this._serviceType = serviceType + + jmdns.addServiceListener(serviceType, object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + logger.debug { "Service added: ${event.name}" } + } + + override fun serviceRemoved(event: ServiceEvent) { + logger.debug { "Service removed: ${event.name}" } + _foundServices.update { + it.filterKeys { key -> key != event.name } + } + } + + override fun serviceResolved(event: ServiceEvent) { + logger.debug { "Service resolved: ${event.name}" } + val mdnsService: MdnsService = MdnsService( + event.info.name, + event.info.type, + event.info.port, + event.info.inetAddresses.map { + it.toMultiplatformIpAddress() + }, + event.info.getTxtRecords() + ) + + _foundServices.update { + it + (event.name to mdnsService) + } + } + }) + } + + override fun stop() { + if (!started.compareAndSet(true, false)) { + return + } + + logger.debug { "Stopping JvmMdnsDiscoverService" } + + jmdns.unregisterAllServices() + _serviceType = null + } + + override fun close() { + logger.debug { "Closing JvmMdnsDiscoverService" } + jmdns.close() + } +} diff --git a/core/src/jvmMain/kotlin/io/github/chrisimx/db/JvmScanBridgeDbBuilderFactory.kt b/core/src/jvmMain/kotlin/io/github/chrisimx/db/JvmScanBridgeDbBuilderFactory.kt new file mode 100644 index 00000000..2e2766d6 --- /dev/null +++ b/core/src/jvmMain/kotlin/io/github/chrisimx/db/JvmScanBridgeDbBuilderFactory.kt @@ -0,0 +1,18 @@ +package io.github.chrisimx.db + +import androidx.room.Room +import androidx.room.RoomDatabase +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory +import io.github.chrisimx.scanbridge.filesystem.StandardDirectoryProvider +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.absolutePath + +class JvmScanBridgeDbBuilderFactory( + val standardDirProvider: StandardDirectoryProvider +) : ScanBridgeDbBuilderFactory { + override fun getBuilder(): RoomDatabase.Builder { + val databasePath = PlatformFile(standardDirProvider.databaseDirectory, "scanbridge.sqlite3") + return Room.databaseBuilder(databasePath.absolutePath()) + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 52c51045..9fd68591 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -4,11 +4,24 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.koin) } dependencies { implementation(project(":composeUI")) + implementation(project(":core")) + + implementation(libs.kotlinx.coroutines.swing) + + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.annotations) + implementation(libs.koin.compose.viewmodel) + + runtimeOnly(libs.logback) } +kotlin { jvmToolchain(21) } + compose.desktop { application { mainClass = "MainKt" @@ -16,7 +29,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "ScanBridge" - packageVersion = "1.0.0" + packageVersion = version.toString() linux { iconFile.set(project.file("appIcons/LinuxIcon.png")) diff --git a/desktopApp/src/main/kotlin/DesktopJvmBuildInfoProvider.kt b/desktopApp/src/main/kotlin/DesktopJvmBuildInfoProvider.kt new file mode 100644 index 00000000..789e3f9c --- /dev/null +++ b/desktopApp/src/main/kotlin/DesktopJvmBuildInfoProvider.kt @@ -0,0 +1,12 @@ +import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider +import io.github.chrisimx.scanbridge.buildinfo.ScanBridgeEdition +import io.github.chrisimx.scanbridge.model.Platform + +class DesktopJvmBuildInfoProvider : BuildInfoProvider { + override val edition: ScanBridgeEdition + get() = ScanBridgeEdition.DESKTOP + override val isDebugBuild: Boolean + get() = false + override val platform: Platform + get() = Platform.DESKTOP +} diff --git a/desktopApp/src/main/kotlin/DesktopJvmExportCapabilitiesProvider.kt b/desktopApp/src/main/kotlin/DesktopJvmExportCapabilitiesProvider.kt new file mode 100644 index 00000000..ecaa69fc --- /dev/null +++ b/desktopApp/src/main/kotlin/DesktopJvmExportCapabilitiesProvider.kt @@ -0,0 +1,6 @@ +import io.github.chrisimx.scanbridge.export.ExportCapabilitiesProvider +import io.github.chrisimx.scanbridge.scanning.FileSaveType + +class DesktopJvmExportCapabilitiesProvider : ExportCapabilitiesProvider { + override val supportedFileSaveTypes: Set = setOf(FileSaveType.Save) +} diff --git a/desktopApp/src/main/kotlin/DesktopJvmImageRotationService.kt b/desktopApp/src/main/kotlin/DesktopJvmImageRotationService.kt new file mode 100644 index 00000000..e24b6d8c --- /dev/null +++ b/desktopApp/src/main/kotlin/DesktopJvmImageRotationService.kt @@ -0,0 +1,59 @@ +import io.github.chrisimx.scanbridge.imagerotation.ImageRotationService +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.vinceglb.filekit.PlatformFile +import java.awt.image.BufferedImage +import javax.imageio.ImageIO +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class DesktopJvmImageRotationService( + loggerFactory: ScanBridgeLoggerFactory +) : ImageRotationService { + val logger = loggerFactory.withClass(this::class) + + override suspend fun rotate90ToRight( + sourceFile: PlatformFile, + outputFile: PlatformFile + ): Boolean = withContext(Dispatchers.IO) { + + try { + val src = ImageIO.read(sourceFile.file) + + val dst = BufferedImage( + src.height, + src.width, + src.type + ) + + val pixels = IntArray(src.width * src.height) + src.getRGB( + 0, 0, + src.width, + src.height, + pixels, + 0, + src.width + ) + + for (y in 0 until src.height) { + for (x in 0 until src.width) { + dst.setRGB( + src.height - 1 - y, + x, + pixels[y * src.width + x] + ) + } + } + + ImageIO.write(dst, "jpg", outputFile.file) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error { "Error while rotating image 90 degrees to right: $e" } + return@withContext false + } + + return@withContext true + } +} diff --git a/desktopApp/src/main/kotlin/DesktopJvmMulticastLockHandler.kt b/desktopApp/src/main/kotlin/DesktopJvmMulticastLockHandler.kt new file mode 100644 index 00000000..da081b4e --- /dev/null +++ b/desktopApp/src/main/kotlin/DesktopJvmMulticastLockHandler.kt @@ -0,0 +1,7 @@ +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler + +class DesktopJvmMulticastLockHandler : MulticastLockHandler { + override fun acquire() {} + + override fun release() {} +} diff --git a/desktopApp/src/main/kotlin/DesktopJvmScanExecutionEntryPoint.kt b/desktopApp/src/main/kotlin/DesktopJvmScanExecutionEntryPoint.kt new file mode 100644 index 00000000..0314cbf1 --- /dev/null +++ b/desktopApp/src/main/kotlin/DesktopJvmScanExecutionEntryPoint.kt @@ -0,0 +1,18 @@ +import io.github.chrisimx.scanbridge.scanning.ScanExecutionEntryPoint +import io.github.chrisimx.scanbridge.scanning.ScanExecutor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class DesktopJvmScanExecutionEntryPoint( + private val scanExecutor: ScanExecutor +) : ScanExecutionEntryPoint { + val scanExecutionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun start() { + scanExecutionScope.launch { + scanExecutor.executeScans() + } + } +} diff --git a/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt b/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt new file mode 100644 index 00000000..1e47ed8d --- /dev/null +++ b/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt @@ -0,0 +1,37 @@ +import coil3.PlatformContext +import io.github.chrisimx.db.JvmScanBridgeDbBuilderFactory +import io.github.chrisimx.scanbridge.StartupTabsProvider +import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider +import io.github.chrisimx.scanbridge.cropfeature.ImageCropService +import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory +import io.github.chrisimx.scanbridge.export.ExportCapabilitiesProvider +import io.github.chrisimx.scanbridge.imagerotation.ImageRotationService +import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService +import io.github.chrisimx.scanbridge.ports.multicast.MulticastLockHandler +import io.github.chrisimx.scanbridge.scanning.ScanExecutionEntryPoint +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.factory +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_JVM_PLATFORM = module { + single() bind ScanBridgeDbBuilderFactory::class + single() bind BuildInfoProvider::class + + single() bind MulticastLockHandler::class + factory() bind MdnsDiscoverService::class + + single() bind ScanExecutionEntryPoint::class + + single() bind ImageRotationService::class + + single() bind StartupTabsProvider::class + + single() bind ExportCapabilitiesProvider::class + + single() bind ImageCropService::class + + single { + PlatformContext.INSTANCE + } +} diff --git a/desktopApp/src/main/kotlin/Main.kt b/desktopApp/src/main/kotlin/Main.kt index be251960..b38aa892 100644 --- a/desktopApp/src/main/kotlin/Main.kt +++ b/desktopApp/src/main/kotlin/Main.kt @@ -2,16 +2,27 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import io.github.chrisimx.scanbridge.App +import io.github.chrisimx.scanbridge.ScanBridgeApp +import io.github.chrisimx.scanbridge.koin.KOIN_MODULE_COMMON +import io.github.chrisimx.scanbridge.koin.KOIN_MODULE_COMPOSE_UI_COMMON +import io.github.vinceglb.filekit.FileKit import java.awt.Dimension +import org.koin.core.context.startKoin -fun main() = application { - Window( - title = "ScanBridge", - state = rememberWindowState(width = 800.dp, height = 600.dp), - onCloseRequest = ::exitApplication - ) { - window.minimumSize = Dimension(350, 600) - App() +fun main() { + FileKit.init("io.github.chrisimx.scanbridge") + startKoin { + modules(KOIN_MODULE_JVM_PLATFORM, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM, KOIN_MODULE_COMMON, KOIN_MODULE_COMPOSE_UI_COMMON) + } + + application { + Window( + title = "ScanBridge", + state = rememberWindowState(width = 800.dp, height = 600.dp), + onCloseRequest = ::exitApplication + ) { + window.minimumSize = Dimension(350, 600) + ScanBridgeApp() + } } } diff --git a/desktopApp/src/main/kotlin/StartupTabDefinitions.kt b/desktopApp/src/main/kotlin/StartupTabDefinitions.kt new file mode 100644 index 00000000..d48e34e0 --- /dev/null +++ b/desktopApp/src/main/kotlin/StartupTabDefinitions.kt @@ -0,0 +1,50 @@ +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import io.github.chrisimx.scanbridge.AppSettingsScreen +import io.github.chrisimx.scanbridge.ScannerBrowser +import io.github.chrisimx.scanbridge.StartupTabDefinition +import io.github.chrisimx.scanbridge.StartupTabsProvider +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.discovery +import scanbridge.composeui.generated.resources.header_scannerbrowser +import scanbridge.composeui.generated.resources.settings + +class DesktopJvmStartupTabsProvider : StartupTabsProvider { + @OptIn(ExperimentalMaterial3Api::class) + override fun getStartupTabs(): List = + listOf( + StartupTabDefinition( + Res.string.discovery, + Res.string.header_scannerbrowser, + Icons.Filled.Home, + Icons.Outlined.Home, + true, + { innerPadding, navController, showCustomDialog, setShowCustomDialog -> + ScannerBrowser( + innerPadding, + navController, + showCustomDialog, + setShowCustomDialog + ) + } + ), + StartupTabDefinition( + Res.string.settings, + Res.string.settings, + Icons.Filled.Settings, + Icons.Outlined.Settings, + false, + { innerPadding, _, _, _ -> + AppSettingsScreen(innerPadding) + } + ) + ) + + +} + +val STARTUP_TABS_PROVIDER = DesktopJvmStartupTabsProvider() diff --git a/gradle.properties b/gradle.properties index fa120c01..434711ba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -46,4 +46,7 @@ android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.r8.optimizedResourceShrinking=false android.builtInKotlin=true -android.newDsl=false \ No newline at end of file +android.newDsl=false + +scanbridgeVersion=2.2.0 +scanbridgeVersionCode=2001005 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ebe9aade..7633545d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,13 @@ [versions] -compose-multiplatform = "1.10.3" +compose-multiplatform = "1.11.1" androidx-activityCompose = "1.12.0" -agp = "9.1.0" +agp = "9.2.1" coil = "3.3.0" concurrentFutures = "1.2.0" constraintlayoutCompose = "1.1.1" +constraintlayoutComposeMultiplatform = "0.8.1" converterGson = "2.9.0" datastore = "1.2.0" anyscankt = "2.1.7" @@ -18,25 +19,27 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" kotlinReflect = "2.1.20" -kotlinxCoroutinesAndroid = "1.7.3" kotlinxSerializationJson = "1.9.0" kotlinxCoroutines = "1.10.2" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" composeBom = "2026.03.01" +navigationComposeVersion = "2.9.2" retrofit = "3.0.0" room = "2.8.4" +skiko = "0.150.1" timber = "5.0.1" zoomable = "0.18.0" material3 = "1.11.0-alpha05" androidx-material3 = "1.5.0-alpha16" materialIcons = "1.7.8" +composeMultiplatformMateriaIcons = "1.7.3" navigationCompose = "2.9.5" versionsPlugin = "0.53.0" screengrab = "2.1.1" ktor = "3.4.0" -koin = "4.2.0" -koin-plugin = "0.6.2" +koin = "4.2.2" +koin-plugin = "1.1.0" protobuf-plugin = "0.9.6" protobuf-kotlin-lite = "4.33.5" rules = "1.7.0" @@ -44,6 +47,13 @@ paraphrase-plugin = "0.4.1" ksp = "2.3.6" protoc = "4.33.5" androidx-sqlite = "2.6.2" +filekit = "0.15.0" +kmp-observable-viewmodel = "1.0.6" +kmp-native-zip = "0.1.1" + +buildkonfig = "0.22.0" +jmdns = "3.6.3" +logback = "1.6.3" [libraries] @@ -65,8 +75,11 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" } +constraintlayout-compose-multiplatform = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayoutComposeMultiplatform" } +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" } retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" } koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin" } @@ -84,7 +97,7 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor"} ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } @@ -93,10 +106,12 @@ androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constr androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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"} +compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMultiplatformMateriaIcons" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-fetcher-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } esclkt = { module = "io.github.chrisimx:esclkt", version.ref = "anyscankt" } +skiko = { module = "org.jetbrains.skiko:skiko", version.ref = "skiko" } 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" } @@ -115,7 +130,8 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { module = "androidx.compose.material3:material3", version.ref= "androidx-material3" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } -kotlinx-coroutines = { module = " org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines"} +kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines"} +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines"} retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } screengrab = { module = "tools.fastlane:screengrab", version.ref = "screengrab" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } @@ -123,9 +139,16 @@ zoomable = { module = "me.saket.telephoto:zoomable", version.ref = "zoomable" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-kotlin-lite"} androidx-rules = { group = "androidx.test", name = "rules", version.ref = "rules" } protoc = { module = "com.google.protobuf:protoc", version.ref = "protoc" } +filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekit"} +filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit"} +kmp-native-zip = { module = "io.github.chrisimx:kmp-native-zip", version.ref = "kmp-native-zip"} escl-mock-server = { module = "io.github.chrisimx:escl-mock-server", version.ref = "escl-mock-server"} +kmp-observable-viewmodel = { module = "com.rickclephas.kmp:kmp-observableviewmodel-core", version.ref = "kmp-observable-viewmodel"} +jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns"} +logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } + [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } @@ -143,4 +166,5 @@ koin = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } room = { id = "androidx.room", version.ref = "room"} paraphrase = { id = "app.cash.paraphrase", version.ref = "paraphrase-plugin"} -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig"} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 41465649..c61a118f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME