From 0fa8d0af77f7e340ff5907e911567c87d0f25523 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sat, 8 Aug 2026 14:47:45 +0200 Subject: [PATCH] Migrate LastUsedScanSettings and AppSettings from DataStore to Room --- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 142 ------- .../chrisimx/scanbridge/ScanBridgeTest.kt | 26 +- .../screenshot/ScanBridgeScreenshotTest.kt | 26 +- .../chrisimx/scanbridge/AppSettingsScreen.kt | 72 ++-- .../scanbridge/ScanBridgeApplication.kt | 29 +- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 16 +- .../data/ui/ScanningScreenViewModel.kt | 52 ++- .../scanbridge/datastore/DataStores.kt | 3 + .../D2RAppSettingsMigrationDataSourceImpl.kt | 45 +++ ...UsedScanSettingsMigrationDataSourceImpl.kt | 23 ++ .../Datastore2RoomMigrationDataSources.kt | 12 + .../services/FileDebugLogService.kt | 9 +- ...> DatastoreLegacyLastScanSettingsStore.kt} | 4 +- .../stores/LegacyCustomScannerStore.kt | 92 ----- .../scanbridge/stores/LegacySessionsStore.kt | 132 ------- .../uicomponents/FoundScannerItem.kt | 2 +- .../settings/VersionComposable.kt | 30 +- .../src/androidMain/res/values/strings.xml | 1 + .../7.json | 364 ++++++++++++++++++ .../appsettings/AppSettingsRepository.kt | 56 +++ .../appsettings/AppSettingsViewModel.kt | 75 ++++ .../D2RAppSettingsMigrationDataSource.kt | 7 + .../appsettings/RoomAppSettingsRepository.kt | 21 + .../chrisimx/scanbridge/db/ScanBridgeDb.kt | 11 +- .../scanbridge/db/ScanBridgeDbFactory.kt | 12 +- .../scanbridge/db/daos/AppSettingsDao.kt | 30 ++ .../db/daos/LastUsedScanSettingsDao.kt | 19 + .../scanbridge/db/entities/AppSettings.kt | 15 + .../db/entities/LastUsedScanSettings.kt | 14 + .../db/migrations/KoinRoomMigrationsModule.kt | 3 +- ...onV5To6.kt => RoomMigrationVersion5To6.kt} | 2 +- .../db/migrations/RoomMigrationVersion6To7.kt | 76 ++++ .../D2RLastUsedScanSettingsMigrationSource.kt | 7 + .../LastUsedScanSettingsRepository.kt | 8 + .../RoomLastUsedScanSettingsRepository.kt | 25 ++ 35 files changed, 943 insertions(+), 518 deletions(-) delete mode 100644 androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RAppSettingsMigrationDataSourceImpl.kt create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RLastUsedScanSettingsMigrationDataSourceImpl.kt create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/Datastore2RoomMigrationDataSources.kt rename androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/{DefaultScanSettingsStore.kt => DatastoreLegacyLastScanSettingsStore.kt} (96%) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt create mode 100644 core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/7.json create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/D2RAppSettingsMigrationDataSource.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/RoomAppSettingsRepository.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/AppSettingsDao.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/LastUsedScanSettingsDao.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/LastUsedScanSettings.kt rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/{RoomMigrationV5To6.kt => RoomMigrationVersion5To6.kt} (93%) create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion6To7.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/D2RLastUsedScanSettingsMigrationSource.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/LastUsedScanSettingsRepository.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/RoomLastUsedScanSettingsRepository.kt diff --git a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt deleted file mode 100644 index 0a7516c6..00000000 --- a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (C) 2024-2025 Christian Nagel and contributors - * - * This file is part of ScanBridge. - * - * ScanBridge is free software: you can redistribute it and/or modify it under the terms of - * the GNU General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. - * - * ScanBridge is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with eSCLKt. - * If not, see . - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package io.github.chrisimx.scanbridge - -import android.app.Application -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.datastore.appSettingsStore -import io.github.chrisimx.scanbridge.proto.scanningResponseTimeoutOrNull -import io.github.chrisimx.scanbridge.uicomponents.FullScreenError -import io.ktor.http.Url -import kotlin.uuid.Uuid -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import timber.log.Timber - -@Serializable -sealed interface BaseRoute - -@Serializable -@SerialName("StartUpScreenRoute") -object StartUpScreenRoute : BaseRoute - -@Serializable -@SerialName("ScannerRoute") -data class ScannerRoute(val scannerName: String, val scannerURL: String, 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" -> StartUpScreenRoute - - "CropImageRoute/{scanId}/{pageIdx}/{returnRoute}" -> { - val scanId = arguments?.getString("scanId") ?: return null - val returnRouteString = arguments?.getString("returnRoute") ?: return null - CropImageRoute(scanId, returnRouteString) - } - - "ScannerRoute/{scannerName}/{scannerURL}/{sessionID}" -> { - val scannerName = arguments?.getString("scannerName") ?: return null - val scannerURL = arguments?.getString("scannerURL") ?: return null - val sessionID = arguments?.getString("sessionID") ?: return null - ScannerRoute(scannerName, scannerURL, sessionID) - } - - "ErrorRoute/{error}" -> { - val error = arguments?.getString("error") ?: return null - ErrorRoute(error) - } - - else -> null - } -} - -@Composable -fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { - val context = LocalContext.current - val appSettings = context.appSettingsStore.data - - NavHost( - modifier = Modifier.testTag("root_node"), - navController = navController, - startDestination = startDestination - ) { - composable { backStackEntry -> - val errorRoute: ErrorRoute = backStackEntry.toRoute() - val errorMessage = errorRoute.error - - FullScreenError(R.drawable.outline_error_24, errorMessage, true) - } - composable { - StartupScreen(navController) - } - 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}") - navController.navigate(StartUpScreenRoute) - return@composable - } - CropScreen(Uuid.parse(scannerRoute.scanId), returnRoute, navController) - } - composable { backStackEntry -> - val scannerRoute: ScannerRoute = backStackEntry.toRoute() - val appSettingsCurrent = runBlocking { appSettings.first() } - val debug = appSettingsCurrent.writeDebug - val certValidationDisabled = appSettingsCurrent.disableCertChecks - val timeout = appSettingsCurrent.scanningResponseTimeoutOrNull?.value?.toUInt() ?: 25u - Timber.tag("ScanBridgeNavHost") - .d( - "Navigating to scanner ${scannerRoute.scannerName} at ${scannerRoute.scannerURL}. Timeout is $timeout seconds, Debug is $debug. Disabling of cert checks is $certValidationDisabled. Session id is ${scannerRoute.sessionID}" - ) - ScanningScreen( - scannerRoute.scannerName, - Url(scannerRoute.scannerURL), - navController, - timeout, - debug, - certValidationDisabled, - Uuid.parse(scannerRoute.sessionID), - context.applicationContext as Application - ) - } - } -} diff --git a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeTest.kt b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeTest.kt index e764baa4..7120af86 100644 --- a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeTest.kt +++ b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/ScanBridgeTest.kt @@ -14,16 +14,12 @@ import androidx.test.espresso.Espresso.pressBack import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import io.github.chrisimx.esclmockserver.EsclMockServer -import io.github.chrisimx.scanbridge.datastore.appSettingsStore -import io.github.chrisimx.scanbridge.datastore.lastRouteStore -import io.github.chrisimx.scanbridge.datastore.shownMessagesStore -import io.github.chrisimx.scanbridge.datastore.updateSettings -import io.github.chrisimx.scanbridge.proto.copy -import kotlinx.coroutines.runBlocking +import io.github.chrisimx.scanbridge.db.ScanBridgeDb import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.koin.mp.KoinPlatform.getKoin class ScanBridgeTest { @get:Rule @@ -37,22 +33,8 @@ class ScanBridgeTest { @Before @After fun cleanupForTest() { - runBlocking { - composeTestRule.activity.shownMessagesStore.updateData { - it.copy { - thankPlayOne = true - } - } - composeTestRule.activity.lastRouteStore.updateData { - it.copy { - clearLastRoute() - } - } - composeTestRule.activity.appSettingsStore.updateSettings { - clearLastUsedScanSettings() - autoCleanup = true - } - } + val scanBridgeDb: ScanBridgeDb = getKoin().get() + scanBridgeDb.clearAllTables() } @Test diff --git a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/screenshot/ScanBridgeScreenshotTest.kt b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/screenshot/ScanBridgeScreenshotTest.kt index 8242c789..9d4bcc24 100644 --- a/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/screenshot/ScanBridgeScreenshotTest.kt +++ b/androidApp/src/androidTest/java/io/github/chrisimx/scanbridge/screenshot/ScanBridgeScreenshotTest.kt @@ -14,15 +14,11 @@ import androidx.test.rule.GrantPermissionRule import io.github.chrisimx.esclmockserver.EsclMockServer import io.github.chrisimx.scanbridge.BuildConfig import io.github.chrisimx.scanbridge.MainActivity -import io.github.chrisimx.scanbridge.datastore.appSettingsStore -import io.github.chrisimx.scanbridge.datastore.lastRouteStore -import io.github.chrisimx.scanbridge.datastore.shownMessagesStore -import io.github.chrisimx.scanbridge.datastore.updateSettings -import io.github.chrisimx.scanbridge.proto.copy -import kotlinx.coroutines.runBlocking +import io.github.chrisimx.scanbridge.db.ScanBridgeDb import org.junit.Before import org.junit.Rule import org.junit.Test +import org.koin.mp.KoinPlatform.getKoin import tools.fastlane.screengrab.Screengrab import tools.fastlane.screengrab.locale.LocaleTestRule @@ -56,22 +52,8 @@ class ScanBridgeScreenshotTest { @Before fun cleanupForTest() { - runBlocking { - composeTestRule.activity.shownMessagesStore.updateData { - it.copy { - thankPlayOne = true - } - } - composeTestRule.activity.lastRouteStore.updateData { - it.copy { - clearLastRoute() - } - } - composeTestRule.activity.appSettingsStore.updateSettings { - clearLastUsedScanSettings() - autoCleanup = true - } - } + val scanBridgeDb: ScanBridgeDb = getKoin().get() + scanBridgeDb.clearAllTables() } @Test diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt index 7ae82423..4bc14d22 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt @@ -51,22 +51,21 @@ 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.LocalContext +import androidx.compose.ui.platform.LocalUriHandler 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 androidx.core.net.toUri +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.proto.ScanBridgeSettings -import io.github.chrisimx.scanbridge.proto.rememberScanSettingsOrNull import io.github.chrisimx.scanbridge.services.DebugLogService import io.github.chrisimx.scanbridge.uicomponents.TitledCard import io.github.chrisimx.scanbridge.uicomponents.dialog.SimpleTextDialog @@ -75,8 +74,6 @@ 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 kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch import org.koin.compose.koinInject @Composable @@ -159,7 +156,6 @@ fun DebugOptions(debugLog: Boolean, onInformationRequested: (Int) -> Unit, setWr }, style = MaterialTheme.typography.bodyMedium ) - val context = LocalContext.current Box( modifier = Modifier .constrainAs(informationButton) { @@ -189,19 +185,23 @@ fun DebugOptions(debugLog: Boolean, onInformationRequested: (Int) -> Unit, setWr @ExperimentalMaterial3Api @Composable fun AppSettingsScreen(innerPadding: PaddingValues) { - val context = LocalContext.current + val vm = koinInject() - val appSettingsStore = context.appSettingsStore - val appSettings by appSettingsStore.data.collectAsState( - ScanBridgeSettings.getDefaultInstance() - ) var information: Int? by remember { mutableStateOf(null) } val setInformationRequested = { it: Int -> information = it } val scrollState = rememberScrollState() - val coroutineScope = rememberCoroutineScope() + + val disableCertChecks by vm.disableCertChecks.collectAsState() + val rememberScanSettings by vm.rememberScanSettings.collectAsState() + val writeDebugLogs by vm.writeDebugLogs.collectAsState() + + val defaultPdfExportChunkSize = vm.getDefaultPdfExportChunkSize() + val defaultScanningResponseTimeout = vm.getDefaultScanningResponseTimeout() + + val uriHandler = LocalUriHandler.current Column( modifier = Modifier @@ -210,9 +210,15 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { - VersionComposable() + VersionComposable( + vm.versionName, + vm.versionCode, + vm.gitCommitHash, + vm.edition, + vm.isDebugBuild + ) - val isFdroidVariant = BuildConfig.FLAVOR == "fdroid" + val isFdroidVariant = vm.edition == ScanBridgeEdition.FDROID val padding = if (isFdroidVariant) 16.dp else 10.dp FlowRow( modifier = Modifier.padding(vertical = padding), @@ -222,8 +228,7 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { if (isFdroidVariant) { Button( onClick = { - val intent = Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/Chrisimx".toUri()) - context.startActivity(intent) + uriHandler.openUri("https://github.com/sponsors/Chrisimx") }, colors = ButtonDefaults.buttonColors( containerColor = Color(0xFFFF6F61), @@ -233,14 +238,13 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { Icon( modifier = Modifier.padding(end = 8.dp), painter = painterResource(R.drawable.favorite_24px), - contentDescription = stringResource(R.string.donate) + contentDescription = null ) Text(stringResource(R.string.donate)) } } OutlinedIconButton(onClick = { - val intent = Intent(Intent.ACTION_VIEW, "https://github.com/Chrisimx/ScanBridge".toUri()) - context.startActivity(intent) + uriHandler.openUri("https://github.com/Chrisimx/ScanBridge") }) { Icon(painterResource(R.drawable.github_mark), contentDescription = stringResource(R.string.source_code)) } @@ -254,41 +258,37 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { ) { DisableCertChecksSetting( setInformationRequested, - appSettings.disableCertChecks, - { coroutineScope.launch { appSettingsStore.setDisableCertCheck(it) } } + disableCertChecks, + vm::setDisableCertValidation ) CheckboxSetting( stringResource(R.string.remember_scan_settings), R.string.remember_scan_settings_desc, - appSettings.rememberScanSettingsOrNull?.value ?: true, - { coroutineScope.launch { appSettingsStore.setRememberScanSettings(it) } } + rememberScanSettings, + vm::setRememberScanSettings ) { information = it } // Timeout setting UIntSetting( - { - appSettingsStore.data.firstOrNull()?.scanningResponseTimeout?.value?.toUInt() ?: 25u - }, - 25u, + { vm.getScanningResponseTimeout() }, + defaultScanningResponseTimeout, stringResource(R.string.timeout), R.string.timeout_info, setInformationRequested, - { coroutineScope.launch { appSettingsStore.setTimeoutSetting(it) } } + vm::setScanningResponseTimeout ) // PDF chunk size setting UIntSetting( - { - appSettingsStore.data.firstOrNull()?.chunkSizePdfExport?.value?.toUInt() ?: 50u - }, - 50u, + { vm.getPdfExportChunkSize() }, + defaultPdfExportChunkSize, stringResource(R.string.pdf_export_max_pages_per_pdf), R.string.pdf_export_setting_info, setInformationRequested, - { coroutineScope.launch { appSettingsStore.setChunkSize(it) } }, + vm::setPdfExportChunkSize, min = 1u, max = UInt.MAX_VALUE ) @@ -298,9 +298,9 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { title = stringResource(R.string.advanced) ) { DebugOptions( - appSettings.writeDebug, + writeDebugLogs, setInformationRequested, - { coroutineScope.launch { appSettingsStore.setWriteDebugLog(it) } } + vm::setWriteDebugLogs ) } } 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 4ec1e1e6..05d8b5fd 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -6,16 +6,16 @@ import AndroidMulticastLockHandler import AndroidScanBridgeDbBuilderFactory import android.app.Application import android.content.Context -import androidx.datastore.core.DataStore import coil3.ImageLoader import coil3.network.ktor3.KtorNetworkFetcherFactory import io.github.chrisimx.scanbridge.adapters.KoinBasedScanningProtocolManager import io.github.chrisimx.scanbridge.adapters.RoomBackedCustomScannerRepository +import io.github.chrisimx.scanbridge.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.ScanSettingsComposableStateHolder import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel -import io.github.chrisimx.scanbridge.datastore.appSettingsStore -import io.github.chrisimx.scanbridge.datastore.shownMessagesStore import io.github.chrisimx.scanbridge.db.DefaultScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory @@ -24,6 +24,7 @@ import io.github.chrisimx.scanbridge.db.migrations.ROOM_MIGRATIONS import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.migrations.MigrationExecutor import io.github.chrisimx.scanbridge.migrations.RoomBackedMigrationExecutor +import io.github.chrisimx.scanbridge.migrations.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 @@ -34,11 +35,12 @@ 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.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages import io.github.chrisimx.scanbridge.repositories.DatastoreLastRouteRepository import io.github.chrisimx.scanbridge.repositories.DatastoreShownMessagesRepository import io.github.chrisimx.scanbridge.repositories.RoomLastRouteRepository +import io.github.chrisimx.scanbridge.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 @@ -65,9 +67,6 @@ import org.koin.plugin.module.dsl.single import org.koin.plugin.module.dsl.viewModel import timber.log.Timber -fun createAppSettingsDataStore(context: Context) = context.appSettingsStore -fun createShownMessagesDataStore(context: Context) = context.shownMessagesStore - fun createScanBridgeDb(factory: ScanBridgeDbFactory): ScanBridgeDb = factory.createInstance() fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): ImageLoader { @@ -91,17 +90,10 @@ fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): .build() } val appModule = module { - single>(named()) { - create(::createShownMessagesDataStore) - } - single>(named()) { - create(::createAppSettingsDataStore) - } single() bind Thread.UncaughtExceptionHandler::class single() bind LocaleProvider::class - single { - FileDebugLogService(get(named()), get()) - } bind DebugLogService::class + single() bind AppSettingsRepository::class + single() bind DebugLogService::class single() bind HttpClientFactory::class single() bind ScanBridgeLoggerFactory::class single() @@ -118,6 +110,7 @@ val appModule = module { factory() bind MdnsDiscoverService::class single() single() bind LastRouteRepository::class + single() bind LastUsedScanSettingsRepository::class single(named("legacyDatastoreShownMessages")) { DatastoreShownMessagesRepository( get(named()), @@ -138,7 +131,9 @@ val appModule = module { single() bind BuildInfoProvider::class - includes(SCAN_PROTOCOLS, ROOM_MIGRATIONS) + viewModel() + + includes(SCAN_PROTOCOLS, ROOM_MIGRATIONS, DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } class ScanBridgeApplication : Application() { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index 5a04ac42..88fb10e3 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -28,14 +28,12 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute -import io.github.chrisimx.scanbridge.datastore.appSettingsStore +import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager -import io.github.chrisimx.scanbridge.proto.scanningResponseTimeoutOrNull import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.TemporaryFileHandler import io.github.chrisimx.scanbridge.util.doTempFilesExist import kotlin.uuid.Uuid -import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName @@ -95,8 +93,8 @@ fun NavBackStackEntry.toTypedRoute(): BaseRoute? { @Composable fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { val context = LocalContext.current - val appSettings = context.appSettingsStore.data val protocolManager = koinInject() + val appSettingsRepo = koinInject() NavHost( modifier = Modifier.testTag("root_node"), @@ -129,10 +127,10 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { } composable { backStackEntry -> val scannerRoute: ScannerRoute = backStackEntry.toRoute() - val appSettingsCurrent = runBlocking { appSettings.first() } - val debug = appSettingsCurrent.writeDebug - val certValidationDisabled = appSettingsCurrent.disableCertChecks - val timeout = appSettingsCurrent.scanningResponseTimeoutOrNull?.value?.toUInt() ?: 25u + val appSettingsCurrent = runBlocking { appSettingsRepo.getAppSettings() } + val debug = appSettingsCurrent.writeDebugLogs + val certValidationDisabled = appSettingsCurrent.disableCertValidation + val timeout = appSettingsCurrent.scanningResponseTimeoutInS val scannerHandle = protocolManager.getScannerHandle( scannerRoute.protocolId, scannerRoute.scannerHandleString @@ -149,7 +147,7 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { scannerRoute.scannerName, scannerHandle, navController, - timeout, + timeout.toUInt(), debug, certValidationDisabled, Uuid.parse(scannerRoute.sessionID) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index e9b6b7f3..5a61584a 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -44,8 +44,10 @@ 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.datastore.appSettingsStore +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 @@ -57,9 +59,8 @@ import io.github.chrisimx.scanbridge.model.toggleRotation import io.github.chrisimx.scanbridge.ports.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.ports.ScannerCapabilitiesResult import io.github.chrisimx.scanbridge.ports.ScannerConnectionSettings -import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull +import io.github.chrisimx.scanbridge.savelastusedscansettings.LastUsedScanSettingsRepository import io.github.chrisimx.scanbridge.services.ScanJobRepository -import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.rotateBy90 @@ -81,7 +82,6 @@ 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 @@ -112,6 +112,8 @@ class ScanningScreenViewModel( val db: ScanBridgeDb, application: Application, val scanJobRepo: ScanJobRepository, + val appSettingsRepository: AppSettingsRepository, + val lastUsedScanSettingsRepo: LastUsedScanSettingsRepository, val initialScanSettingsProvider: InitialScanSettingsProvider, val startScanUseCase: StartScanUseCase ) : AndroidViewModel(application) { @@ -161,11 +163,18 @@ class ScanningScreenViewModel( init { session - .map { it?.currentScanSettings to it?.currentSettingsUIData } + .filter { it?.currentScanSettings != null && it.currentSettingsUIData != null } + .map { + LastUsedScanSettings( + lastUsedScanSettings = it!!.currentScanSettings!!, + lastUsedScanSettingsEnterable = it.currentSettingsUIData!! + ) + } .distinctUntilChanged() - .filter { it.first != null } - .onEach { (scanSettings, settingsUiState) -> - DefaultScanSettingsStore.save(application, scanSettings!!, settingsUiState) + .onEach { newLastUsedScanSettings -> + lastUsedScanSettingsRepo.setLastUsedScanSettings( + newLastUsedScanSettings + ) } .launchIn(viewModelScope) } @@ -337,12 +346,15 @@ class ScanningScreenViewModel( } } else { // Try to load saved scan settings first, fallback to defaults if none exist - val savedSettingsPair = DefaultScanSettingsStore.load(application.applicationContext) - val (savedSettings, savedSettingsUiState) = savedSettingsPair - val initialSettings = if (savedSettings != null) { + val savedSettings = lastUsedScanSettingsRepo.getLastUsedScanSettings() + + val lastUsedScanSettings = savedSettings?.lastUsedScanSettings + val lastUsedScanSettingsEnterable = savedSettings?.lastUsedScanSettingsEnterable + + val initialSettings = if (lastUsedScanSettings != null) { val editor = CommonScanSettingsEditor( caps, - savedSettings + lastUsedScanSettings ) editor.build() } else { @@ -354,16 +366,20 @@ class ScanningScreenViewModel( editor.build() } - val savedSettingsUiStateWithCaps = savedSettingsUiState?.copy() - - sessionDao.insertAll(Session(sessionID, initialSettings, savedSettingsUiState)) + sessionDao.insertAll( + Session( + sessionID, + initialSettings, + lastUsedScanSettingsEnterable + ) + ) _scanningScreenData.scanSettingsVM.value = getKoin().get { parametersOf( MutableStateFlow(caps).asStateFlow(), session.map { it?.currentScanSettings ?: initialSettings } .stateIn(viewModelScope, SharingStarted.Lazily, initialSettings), - savedSettingsUiStateWithCaps ?: defaultScanSettingsUIData, + lastUsedScanSettingsEnterable ?: defaultScanSettingsUIData, updateSettings, viewModelScope ) @@ -495,10 +511,10 @@ class ScanningScreenViewModel( var pageCounter = 0 val chunkSize = try { - application.appSettingsStore.data.first().chunkSizePdfExportOrNull?.value ?: 50 + appSettingsRepository.getAppSettings().chunkSizeForPDFExport } catch (exception: Exception) { Timber.e("doPdfExport couldn't access app settings. Returning default value: $exception") - 50 + AppSettings().chunkSizeForPDFExport } val chunks = currentScans.chunked(chunkSize) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt index 8ca88fc2..25224149 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt @@ -11,6 +11,7 @@ import io.github.chrisimx.scanbridge.proto.LastRoute import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import io.github.chrisimx.scanbridge.proto.ShownMessages +@Deprecated("Use the corresponding repository instead") val Context.appSettingsStore: DataStore by dataStore( fileName = "settings.pb", serializer = ScanBridgeSettingsSerializer, @@ -37,6 +38,7 @@ val Context.appSettingsStore: DataStore by dataStore( } ) +@Deprecated("Use the corresponding repository instead") val Context.lastRouteStore: DataStore by dataStore( fileName = "route_store.pb", serializer = LastRouteStoreSerializer, @@ -57,6 +59,7 @@ val Context.lastRouteStore: DataStore by dataStore( } ) +@Deprecated("Use the corresponding repository instead") val Context.shownMessagesStore: DataStore by dataStore( fileName = "shown_messages.pb", serializer = ShownMessagesSerializer diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RAppSettingsMigrationDataSourceImpl.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RAppSettingsMigrationDataSourceImpl.kt new file mode 100644 index 00000000..28435ce7 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RAppSettingsMigrationDataSourceImpl.kt @@ -0,0 +1,45 @@ +package io.github.chrisimx.scanbridge.migrations.ds2room + +import android.content.Context +import io.github.chrisimx.scanbridge.appsettings.D2RAppSettingsMigrationDataSource +import io.github.chrisimx.scanbridge.datastore.appSettingsStore +import io.github.chrisimx.scanbridge.db.entities.AppSettings +import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings +import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull +import io.github.chrisimx.scanbridge.proto.rememberScanSettingsOrNull +import io.github.chrisimx.scanbridge.proto.scanningResponseTimeoutOrNull +import kotlinx.coroutines.flow.first + +class D2RAppSettingsMigrationDataSourceImpl(context: Context) : D2RAppSettingsMigrationDataSource { + private val appSettingsStore = context.appSettingsStore + + override suspend fun getAppSettings(): AppSettings { + val dataStoreAppSettings = appSettingsStore.data.first() + + return dataStoreAppSettings.toAppSettings() + } + + private fun ScanBridgeSettings.toAppSettings(): AppSettings { + // The defaults are set in the AppSettings constructor + // To use them, we create a new instance of AppSettings with these default params + // and then migrate the values from the legacy datastore to the new that actually have a value. + var appSettings = AppSettings( + writeDebugLogs = this.writeDebug, + disableCertValidation = this.disableCertChecks + ) + + this.scanningResponseTimeoutOrNull?.let { + appSettings = appSettings.copy(scanningResponseTimeoutInS = it.value) + } + + this.chunkSizePdfExportOrNull?.let { + appSettings = appSettings.copy(chunkSizeForPDFExport = it.value) + } + + this.rememberScanSettingsOrNull?.let { + appSettings = appSettings.copy(rememberScanSettings = it.value) + } + + return appSettings + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RLastUsedScanSettingsMigrationDataSourceImpl.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RLastUsedScanSettingsMigrationDataSourceImpl.kt new file mode 100644 index 00000000..428e1f01 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/D2RLastUsedScanSettingsMigrationDataSourceImpl.kt @@ -0,0 +1,23 @@ +package io.github.chrisimx.scanbridge.migrations.ds2room + +import android.content.Context +import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings +import io.github.chrisimx.scanbridge.savelastusedscansettings.D2RLastUsedScanSettingsMigrationSource +import io.github.chrisimx.scanbridge.stores.DatastoreLegacyLastScanSettingsStore + +class D2RLastUsedScanSettingsMigrationDataSourceImpl(val context: Context) : D2RLastUsedScanSettingsMigrationSource { + + @Suppress("DEPRECATION") + override suspend fun getLastUsedScanSettings(): LastUsedScanSettings? { + val (settings, enterableData) = DatastoreLegacyLastScanSettingsStore.load(context) + + return settings?.let { settings -> + enterableData?.let { enterableData -> + LastUsedScanSettings( + lastUsedScanSettings = settings, + lastUsedScanSettingsEnterable = enterableData + ) + } + } + } +} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/Datastore2RoomMigrationDataSources.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/Datastore2RoomMigrationDataSources.kt new file mode 100644 index 00000000..599fd404 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/ds2room/Datastore2RoomMigrationDataSources.kt @@ -0,0 +1,12 @@ +package io.github.chrisimx.scanbridge.migrations.ds2room + +import io.github.chrisimx.scanbridge.appsettings.D2RAppSettingsMigrationDataSource +import io.github.chrisimx.scanbridge.savelastusedscansettings.D2RLastUsedScanSettingsMigrationSource +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES = module { + single() bind D2RAppSettingsMigrationDataSource::class + single() bind D2RLastUsedScanSettingsMigrationSource::class +} 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 index 7fdd3017..ba7ed702 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/FileDebugLogService.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/FileDebugLogService.kt @@ -2,29 +2,26 @@ package io.github.chrisimx.scanbridge.services import android.app.Application import android.net.Uri -import androidx.datastore.core.DataStore import io.github.chrisimx.scanbridge.BuildConfig +import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository import io.github.chrisimx.scanbridge.logs.FileLogger -import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings import java.io.BufferedWriter import java.io.File import java.io.FileWriter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import timber.log.Timber -class FileDebugLogService(appSettings: DataStore, val application: Application) : DebugLogService { +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.data.map { it.writeDebug }.distinctUntilChanged() + private val debugLogActive = appSettings.getWriteDebugLogsFlow() init { debugLogActive.onEach { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DatastoreLegacyLastScanSettingsStore.kt similarity index 96% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt rename to androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DatastoreLegacyLastScanSettingsStore.kt index edcf7493..99ac4ce1 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DefaultScanSettingsStore.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/DatastoreLegacyLastScanSettingsStore.kt @@ -32,12 +32,13 @@ import io.github.chrisimx.scanbridge.proto.rememberScanSettingsOrNull import kotlinx.coroutines.flow.first import timber.log.Timber -object DefaultScanSettingsStore { +object DatastoreLegacyLastScanSettingsStore { private suspend fun isRememberSettingsEnabled(context: Context): Boolean { val appPreferences = context.appSettingsStore.data.first() return appPreferences.rememberScanSettingsOrNull?.value ?: true } + @Deprecated("Use LastUsedScanSettingsRepository instead") suspend fun save(context: Context, scanSettings: CommonScanSettings, uiStateData: ScanSettingsEnterableDataV1?) { if (!isRememberSettingsEnabled(context)) { Timber.d("Scan settings persistence is disabled, skipping save") @@ -61,6 +62,7 @@ object DefaultScanSettingsStore { } } + @Deprecated("Use LastUsedScanSettingsRepository instead") suspend fun load(context: Context): Pair { if (!isRememberSettingsEnabled(context)) { Timber.d("Scan settings persistence is disabled, returning null") diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt deleted file mode 100644 index 2a3dd930..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacyCustomScannerStore.kt +++ /dev/null @@ -1,92 +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.stores - -import android.content.Context -import androidx.core.content.edit -import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.db.entities.CustomScanner -import io.ktor.http.Url -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import timber.log.Timber - -object LegacyCustomScannerStore { - private const val PREF_NAME = "custom_scanner_store" - - @OptIn(ExperimentalUuidApi::class) - @Deprecated("Was replaced by Room DB access") - fun load(context: Context): List { - val sharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - val scannerList = mutableListOf() - - val count = sharedPreferences.getInt("count", 0) - - for (i in 0 until count) { - val name = sharedPreferences.getString("$i.name", null) - val url = sharedPreferences.getString("$i.url", null) - val uuid = sharedPreferences.getString("$i.uuid", null) - - if (name == null || url == null || uuid == null) { - Timber.e("Custom Scanner information that should be in stored in shared preferences is missing!") - continue - } - - try { - val scanner = CustomScanner( - name = name, - url = Url(url), - uuid = Uuid.parse(uuid), - protocolIdentifier = "eSCL" - ) - scannerList.add(scanner) - } catch (e: IllegalArgumentException) { - Timber.e(e, "Invalid URL for custom scanner: $url") - continue - } - } - return scannerList - } - - @OptIn(ExperimentalUuidApi::class) - fun clear(context: Context) { - val sharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - sharedPreferences.edit { - clear() - } - } - - // Room migration - fun ScanBridgeDb.migrateLegacyCustomScanners(context: Context): ScanBridgeDb { - val db = this - val legacyData = load(context) - if (legacyData.isNotEmpty()) { - CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { - db.customScannerDao().insertAll(*legacyData.toTypedArray()) - clear(context) - } - } - return this - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt deleted file mode 100644 index 5db4d7fd..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/stores/LegacySessionsStore.kt +++ /dev/null @@ -1,132 +0,0 @@ -package io.github.chrisimx.scanbridge.stores - -import android.content.Context -import androidx.room.immediateTransaction -import androidx.room.useWriterConnection -import io.github.chrisimx.esclkt.ScannerCapabilities -import io.github.chrisimx.esclkt.anyscancompat.toCommonAbstraction -import io.github.chrisimx.scanbridge.ScanSettingsJson -import io.github.chrisimx.scanbridge.data.model.LegacySessionV2 -import io.github.chrisimx.scanbridge.data.model.LegacySessionV2.Companion.fromString -import io.github.chrisimx.scanbridge.datastore.appSettingsStore -import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.db.entities.ScannedPage -import io.github.chrisimx.scanbridge.db.entities.Session -import io.github.chrisimx.scanbridge.db.entities.TempFile -import io.github.chrisimx.scanbridge.proto.copy -import java.io.File -import java.nio.file.Files -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.io.path.Path -import kotlin.uuid.Uuid -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import timber.log.Timber - -object LegacySessionsStore { - - @OptIn(ExperimentalAtomicApi::class) - private val migrationStarted = AtomicBoolean(false) - - @Deprecated("Replaced with Room database") - fun loadSession(application: Context, sessionID: String, caps: ScannerCapabilities?): Result { - Timber.d("Loading session $sessionID") - - val path = application.applicationInfo.dataDir + "/files/" + sessionID + ".session" - val file = File(path) - - if (!file.exists()) { - Timber.d("Could not find session file at $path") - return Result.success(null) - } - - val sessionFileString = file.readText() - - return fromString(sessionFileString, ScanSettingsJson.json, caps) - } - - @OptIn(ExperimentalAtomicApi::class) - fun ScanBridgeDb.migrateLegacySessions(context: Context): ScanBridgeDb { - if (!migrationStarted.compareAndSet(expectedValue = false, newValue = true)) { - return this - } - CoroutineScope(Dispatchers.IO).launch { - try { - val migrationDone = - context.appSettingsStore.data.firstOrNull()?.migratedSessionFiles ?: false - - if (migrationDone) { - Timber.i("Migrating legacy sessions already done. Skipping!") - return@launch - } - - Timber.i("Migrating legacy sessions") - - val sessionDir = Path(context.applicationInfo.dataDir, "files") - - val db = this@migrateLegacySessions - - val sessionIds = sessionDir.toFile() - .listFiles() - ?.asSequence() - ?.filter { it.isFile && it.extension == "session" } - ?.map { it.nameWithoutExtension } - ?.filter { it.isNotBlank() } - ?.toList() ?: emptyList() - - sessionIds.forEach { sessionIdString -> - val legacySession = - loadSession(context, sessionIdString, null).getOrNull() ?: return@forEach - - val sessionId = Uuid.parse(legacySession.sessionID) - - db.useWriterConnection { - it.immediateTransaction { - db.insertLegacySessionData(sessionId, legacySession) - } - } - - Files.deleteIfExists(sessionDir.resolve("$sessionIdString.session")) - } - - context.appSettingsStore.updateData { - it.copy { migratedSessionFiles = true } - } - } catch (e: Exception) { - Timber.e(e, "Legacy migration failed") - } finally { - migrationStarted.store(false) - } - } - - return this - } - - private suspend fun ScanBridgeDb.insertLegacySessionData(sessionId: Uuid, legacySession: LegacySessionV2) { - sessionDao().insertAll( - Session(sessionId, legacySession.scanSettings?.toCommonAbstraction(), null) - ) - - tmpFileDao().insertAllList( - legacySession.tmpFiles.map { - TempFile(ownerSessionId = sessionId, path = it) - } - ) - - scannedPageDao().insertAllList( - legacySession.scannedPages.mapIndexed { index, page -> - ScannedPage( - scanId = Uuid.generateV4(), - ownerSessionId = sessionId, - filePath = page.filePath, - originalScanSettings = page.originalScanSettings.toCommonAbstraction(), - rotation = page.rotation, - orderIndex = index - ) - } - ) - } -} diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index f48d5bc3..26683e67 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt @@ -122,7 +122,7 @@ fun FoundScannerItem( if (iconUrl != null) { AsyncImage( - model = iconUrl.toString(), + model = iconUrl, contentDescription = stringResource(id = R.string.print_symbol_desc), imageLoader = imageLoader, modifier = Modifier diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt index 58c7cfdd..3ea16f00 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle @@ -38,15 +37,20 @@ 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.BuildConfig 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 @Composable -fun VersionComposable() { - val context = LocalContext.current +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) +} +@Composable +fun VersionComposable(versionName: String, versionCode: Int, gitCommitHash: String, edition: ScanBridgeEdition, debugBuild: Boolean) { Image( modifier = Modifier .size(200.dp) @@ -67,20 +71,16 @@ fun VersionComposable() { ) Text( - "${BuildConfig.VERSION_NAME.removeSuffix("-play")} (${BuildConfig.VERSION_CODE}, ${BuildConfig.GIT_COMMIT_HASH})", + "${versionName.removeSuffix("-play")} ($versionCode, $gitCommitHash)", fontStyle = FontStyle.Normal, fontFamily = Poppins() ) val editionNotice = mutableListOf() - if (BuildConfig.FLAVOR == "fdroid") { - editionNotice.add(stringResource(R.string.f_droid)) - } else { - editionNotice.add(stringResource(R.string.google_play)) - } + editionNotice.add(edition.toLocalizedString()) - if (BuildConfig.DEBUG) { + if (debugBuild) { editionNotice.add(stringResource(R.string.debug_build)) } @@ -99,6 +99,12 @@ fun VersionComposablePreview() { .width(300.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - VersionComposable() + VersionComposable( + "2.1.0", + 2100, + "abdfefg", + ScanBridgeEdition.FDROID, + true + ) } } diff --git a/composeUI/src/androidMain/res/values/strings.xml b/composeUI/src/androidMain/res/values/strings.xml index a06733db..9846b4f6 100644 --- a/composeUI/src/androidMain/res/values/strings.xml +++ b/composeUI/src/androidMain/res/values/strings.xml @@ -134,4 +134,5 @@ Preview 3D Object Business Card + iOS \ No newline at end of file diff --git a/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/7.json b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/7.json new file mode 100644 index 00000000..34471941 --- /dev/null +++ b/core/schemas/io.github.chrisimx.scanbridge.db.ScanBridgeDb/7.json @@ -0,0 +1,364 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "392c2a55e575de7cc057617ba14b4b70", + "entities": [ + { + "tableName": "customscanners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `protocolIdentifier` TEXT NOT NULL DEFAULT 'eSCL', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocolIdentifier", + "columnName": "protocolIdentifier", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'eSCL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "scannedpages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scanId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `filePath` TEXT NOT NULL, `originalScanSettings` TEXT NOT NULL, `rotation` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `outputName` TEXT, PRIMARY KEY(`scanId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "scanId", + "columnName": "scanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerSessionId", + "columnName": "ownerSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalScanSettings", + "columnName": "originalScanSettings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rotation", + "columnName": "rotation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputName", + "columnName": "outputName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "scanId" + ] + }, + "indices": [ + { + "name": "index_scannedpages_ownerSessionId", + "unique": false, + "columnNames": [ + "ownerSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scannedpages_ownerSessionId` ON `${TABLE_NAME}` (`ownerSessionId`)" + }, + { + "name": "index_scannedpages_ownerSessionId_orderIndex", + "unique": true, + "columnNames": [ + "ownerSessionId", + "orderIndex" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scannedpages_ownerSessionId_orderIndex` ON `${TABLE_NAME}` (`ownerSessionId`, `orderIndex`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerSessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `currentScanSettings` TEXT, `currentSettingsUIData` TEXT DEFAULT null, `currentPage` INTEGER NOT NULL, PRIMARY KEY(`sessionId`))", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentScanSettings", + "columnName": "currentScanSettings", + "affinity": "TEXT" + }, + { + "fieldPath": "currentSettingsUIData", + "columnName": "currentSettingsUIData", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "currentPage", + "columnName": "currentPage", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + } + }, + { + "tableName": "tempfiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tempFileId` TEXT NOT NULL, `ownerSessionId` TEXT NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`tempFileId`), FOREIGN KEY(`ownerSessionId`) REFERENCES `sessions`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tempFileId", + "columnName": "tempFileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerSessionId", + "columnName": "ownerSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tempFileId" + ] + }, + "indices": [ + { + "name": "index_tempfiles_ownerSessionId", + "unique": false, + "columnNames": [ + "ownerSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tempfiles_ownerSessionId` ON `${TABLE_NAME}` (`ownerSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerSessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "lastroute", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`route` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "route", + "columnName": "route", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "executedmigrationtoroom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`migrationId` TEXT NOT NULL, PRIMARY KEY(`migrationId`))", + "fields": [ + { + "fieldPath": "migrationId", + "columnName": "migrationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "migrationId" + ] + } + }, + { + "tableName": "shownstartupmessages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`message` TEXT NOT NULL, PRIMARY KEY(`message`))", + "fields": [ + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "message" + ] + } + }, + { + "tableName": "appsettings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `writeDebugLogs` INTEGER NOT NULL, `disableCertValidation` INTEGER NOT NULL, `scanningResponseTimeoutInS` INTEGER NOT NULL, `chunkSizeForPDFExport` INTEGER NOT NULL, `rememberScanSettings` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "writeDebugLogs", + "columnName": "writeDebugLogs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableCertValidation", + "columnName": "disableCertValidation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scanningResponseTimeoutInS", + "columnName": "scanningResponseTimeoutInS", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chunkSizeForPDFExport", + "columnName": "chunkSizeForPDFExport", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rememberScanSettings", + "columnName": "rememberScanSettings", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "lastusedscansettings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `lastUsedScanSettings` TEXT NOT NULL, `lastUsedScanSettingsEnterable` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsedScanSettings", + "columnName": "lastUsedScanSettings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsedScanSettingsEnterable", + "columnName": "lastUsedScanSettingsEnterable", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '392c2a55e575de7cc057617ba14b4b70')" + ] + } +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt new file mode 100644 index 00000000..af2699c1 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsRepository.kt @@ -0,0 +1,56 @@ +package io.github.chrisimx.scanbridge.appsettings + +import io.github.chrisimx.scanbridge.db.entities.AppSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface AppSettingsRepository { + suspend fun getAppSettings(): AppSettings + fun getAppSettingsFlow(): Flow + + suspend fun setAppSettings(appSettings: AppSettings) + suspend fun updateAppSettings(updateOperation: AppSettings.() -> AppSettings) + suspend fun resetAppSettings() { + setAppSettings(AppSettings()) + } + + suspend fun setWriteDebugLogs(value: Boolean) = updateAppSettings { + copy(writeDebugLogs = value) + } + + fun getWriteDebugLogsFlow(): Flow = getAppSettingsFlow().map { + it.writeDebugLogs + } + + suspend fun setDisableCertValidation(value: Boolean) = updateAppSettings { + copy(disableCertValidation = value) + } + + fun getDisableCertValidationFlow(): Flow = getAppSettingsFlow().map { + it.disableCertValidation + } + + suspend fun setScanningResponseTimeout(value: Int) = updateAppSettings { + copy(scanningResponseTimeoutInS = value) + } + + fun getScanningResponseTimeoutFlow(): Flow = getAppSettingsFlow().map { + it.scanningResponseTimeoutInS + } + + suspend fun setPdfExportChunkSize(value: Int) = updateAppSettings { + copy(chunkSizeForPDFExport = value) + } + + fun getPdfExportChunkSizeFlow(): Flow = getAppSettingsFlow().map { + it.chunkSizeForPDFExport + } + + suspend fun setRememberScanSettings(value: Boolean) = updateAppSettings { + copy(rememberScanSettings = value) + } + + fun getRememberScanSettingsFlow(): Flow = getAppSettingsFlow().map { + it.rememberScanSettings + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt new file mode 100644 index 00000000..ca3b952b --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/AppSettingsViewModel.kt @@ -0,0 +1,75 @@ +package io.github.chrisimx.scanbridge.appsettings + +import com.rickclephas.kmp.observableviewmodel.ViewModel +import com.rickclephas.kmp.observableviewmodel.launch +import com.rickclephas.kmp.observableviewmodel.stateIn +import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider +import io.github.chrisimx.scanbridge.db.entities.AppSettings +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map + +class AppSettingsViewModel(val appSettingsRepository: AppSettingsRepository, buildInfoProvider: BuildInfoProvider) : ViewModel() { + val versionName = buildInfoProvider.versionName + val versionCode = buildInfoProvider.versionCode + val gitCommitHash = buildInfoProvider.commit + val edition = buildInfoProvider.edition + val isDebugBuild = buildInfoProvider.isDebugBuild + + val disableCertChecks = appSettingsRepository.getDisableCertValidationFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings().disableCertValidation) + val rememberScanSettings = appSettingsRepository.getRememberScanSettingsFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings().rememberScanSettings) + val scanningResponseTimeout = appSettingsRepository.getScanningResponseTimeoutFlow() + .map { it.toUInt() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings().scanningResponseTimeoutInS.toUInt()) + val pdfChunkSize = appSettingsRepository.getPdfExportChunkSizeFlow() + .map { it.toUInt() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings().chunkSizeForPDFExport.toUInt()) + val writeDebugLogs = appSettingsRepository.getWriteDebugLogsFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings().writeDebugLogs) + + fun setDisableCertValidation(value: Boolean) { + viewModelScope.launch { + appSettingsRepository.setDisableCertValidation(value) + } + } + + fun setRememberScanSettings(value: Boolean) { + viewModelScope.launch { + appSettingsRepository.setRememberScanSettings(value) + } + } + + fun setScanningResponseTimeout(value: UInt) { + if (value > Int.MAX_VALUE.toUInt()) { + return + } + + viewModelScope.launch { + appSettingsRepository.setScanningResponseTimeout(value.toInt()) + } + } + + fun setPdfExportChunkSize(value: UInt) { + if (value > Int.MAX_VALUE.toUInt()) { + return + } + + viewModelScope.launch { + appSettingsRepository.setPdfExportChunkSize(value.toInt()) + } + } + + fun setWriteDebugLogs(value: Boolean) { + viewModelScope.launch { + appSettingsRepository.setWriteDebugLogs(value) + } + } + + fun getDefaultPdfExportChunkSize(): UInt = AppSettings().chunkSizeForPDFExport.toUInt() + fun getDefaultScanningResponseTimeout(): UInt = AppSettings().scanningResponseTimeoutInS.toUInt() + + suspend fun getPdfExportChunkSize(): UInt = appSettingsRepository.getAppSettings().chunkSizeForPDFExport.toUInt() + + suspend fun getScanningResponseTimeout(): UInt = appSettingsRepository.getAppSettings().scanningResponseTimeoutInS.toUInt() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/D2RAppSettingsMigrationDataSource.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/D2RAppSettingsMigrationDataSource.kt new file mode 100644 index 00000000..5ec470e4 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/D2RAppSettingsMigrationDataSource.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.appsettings + +import io.github.chrisimx.scanbridge.db.entities.AppSettings + +interface D2RAppSettingsMigrationDataSource { + suspend fun getAppSettings(): AppSettings +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/RoomAppSettingsRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/RoomAppSettingsRepository.kt new file mode 100644 index 00000000..014ffa08 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/RoomAppSettingsRepository.kt @@ -0,0 +1,21 @@ +package io.github.chrisimx.scanbridge.appsettings + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.AppSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RoomAppSettingsRepository(scanBridgeDb: ScanBridgeDb) : AppSettingsRepository { + private val appSettingsDao = scanBridgeDb.appSettingsDao() + + override suspend fun getAppSettings(): AppSettings = appSettingsDao.getAppSettings() ?: AppSettings() + + override fun getAppSettingsFlow(): Flow = appSettingsDao.getAppSettingsFlow().map { + it ?: AppSettings() + } + + override suspend fun setAppSettings(appSettings: AppSettings) = appSettingsDao.setAppSettings(appSettings) + + override suspend fun updateAppSettings(updateOperation: AppSettings.() -> AppSettings) = + appSettingsDao.updateAppSettings(updateOperation) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt index d016f35f..db4c03b7 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDb.kt @@ -4,16 +4,20 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import io.github.chrisimx.scanbridge.db.daos.AppSettingsDao import io.github.chrisimx.scanbridge.db.daos.CustomScannerDao import io.github.chrisimx.scanbridge.db.daos.ExecutedMigrationsDao import io.github.chrisimx.scanbridge.db.daos.LastRouteDao +import io.github.chrisimx.scanbridge.db.daos.LastUsedScanSettingsDao import io.github.chrisimx.scanbridge.db.daos.ScannedPageDao import io.github.chrisimx.scanbridge.db.daos.SessionDao import io.github.chrisimx.scanbridge.db.daos.ShownStartupMessageDao import io.github.chrisimx.scanbridge.db.daos.TempFileDao +import io.github.chrisimx.scanbridge.db.entities.AppSettings import io.github.chrisimx.scanbridge.db.entities.CustomScanner import io.github.chrisimx.scanbridge.db.entities.ExecutedMigrationToRoom import io.github.chrisimx.scanbridge.db.entities.LastRoute +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.ShownStartupMessage @@ -30,9 +34,10 @@ import io.github.chrisimx.scanbridge.db.typeconverters.UuidTypeConverter CustomScanner::class, ScannedPage::class, Session::class, TempFile::class, LastRoute::class, ExecutedMigrationToRoom::class, - ShownStartupMessage::class + ShownStartupMessage::class, AppSettings::class, + LastUsedScanSettings::class ], - version = 6, + version = 7, autoMigrations = [ AutoMigration( from = 1, @@ -65,4 +70,6 @@ abstract class ScanBridgeDb : RoomDatabase() { abstract fun lastRouteDao(): LastRouteDao abstract fun executedMigrationsDao(): ExecutedMigrationsDao abstract fun shownStartupMessageDao(): ShownStartupMessageDao + abstract fun appSettingsDao(): AppSettingsDao + abstract fun lastUsedScanSettingsDao(): LastUsedScanSettingsDao } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt index fbc043d9..3a97b7dd 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/ScanBridgeDbFactory.kt @@ -2,7 +2,8 @@ package io.github.chrisimx.scanbridge.db import ROOM_MIGRATION_4_5 import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import io.github.chrisimx.scanbridge.db.migrations.RoomMigrationV5To6 +import io.github.chrisimx.scanbridge.db.migrations.RoomMigrationVersion5To6 +import io.github.chrisimx.scanbridge.db.migrations.RoomMigrationVersion6To7 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -10,14 +11,17 @@ interface ScanBridgeDbFactory { fun createInstance(): ScanBridgeDb } -class DefaultScanBridgeDbFactory(val builderFactory: ScanBridgeDbBuilderFactory, val migrationV5To6: RoomMigrationV5To6) : - ScanBridgeDbFactory { +class DefaultScanBridgeDbFactory( + val builderFactory: ScanBridgeDbBuilderFactory, + val migrationV5To6: RoomMigrationVersion5To6, + val migrationV6To7: RoomMigrationVersion6To7 +) : ScanBridgeDbFactory { override fun createInstance(): ScanBridgeDb { val dbBuilder = builderFactory.getBuilder() return dbBuilder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) - .addMigrations(ROOM_MIGRATION_4_5, migrationV5To6) + .addMigrations(ROOM_MIGRATION_4_5, migrationV5To6, migrationV6To7) .build() } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/AppSettingsDao.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/AppSettingsDao.kt new file mode 100644 index 00000000..4e7493ad --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/AppSettingsDao.kt @@ -0,0 +1,30 @@ +package io.github.chrisimx.scanbridge.db.daos + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.github.chrisimx.scanbridge.db.entities.AppSettings +import kotlinx.coroutines.flow.Flow + +@Dao +interface AppSettingsDao { + @Query("SELECT * FROM appsettings WHERE id = 1") + suspend fun getAppSettings(): AppSettings? + + @Query("SELECT * FROM appsettings WHERE id = 1") + fun getAppSettingsFlow(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setAppSettings(appSettings: AppSettings) + + @Transaction + suspend fun updateAppSettings(updateOperation: AppSettings.() -> AppSettings) { + val appSettings = getAppSettings() ?: AppSettings() + setAppSettings(appSettings.updateOperation()) + } + + @Query("DELETE FROM appsettings WHERE id = 1") + suspend fun clearAppSettings() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/LastUsedScanSettingsDao.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/LastUsedScanSettingsDao.kt new file mode 100644 index 00000000..36d1ed85 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/daos/LastUsedScanSettingsDao.kt @@ -0,0 +1,19 @@ +package io.github.chrisimx.scanbridge.db.daos + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings + +@Dao +interface LastUsedScanSettingsDao { + @Query("SELECT * FROM lastusedscansettings WHERE id = 1") + suspend fun getLastUsedScanSettings(): LastUsedScanSettings? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setLastUsedScanSettings(lastUsedScanSettings: LastUsedScanSettings) + + @Query("DELETE FROM lastusedscansettings WHERE id = 1") + suspend fun clearLastUsedScanSettings() +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt new file mode 100644 index 00000000..b009b70d --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/AppSettings.kt @@ -0,0 +1,15 @@ +package io.github.chrisimx.scanbridge.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "appsettings") +data class AppSettings( + @PrimaryKey + val id: Int = 1, + val writeDebugLogs: Boolean = false, + val disableCertValidation: Boolean = false, + val scanningResponseTimeoutInS: Int = 25, + val chunkSizeForPDFExport: Int = 50, + val rememberScanSettings: Boolean = true +) diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/LastUsedScanSettings.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/LastUsedScanSettings.kt new file mode 100644 index 00000000..5ae7938a --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/entities/LastUsedScanSettings.kt @@ -0,0 +1,14 @@ +package io.github.chrisimx.scanbridge.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.github.chrisimx.anyscan.CommonScanSettings +import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 + +@Entity(tableName = "lastusedscansettings") +data class LastUsedScanSettings( + @PrimaryKey + val id: Int = 1, + val lastUsedScanSettings: CommonScanSettings, + val lastUsedScanSettingsEnterable: ScanSettingsEnterableDataV1 +) 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 82491b9f..6d2a37c5 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 @@ -4,5 +4,6 @@ import org.koin.dsl.module import org.koin.plugin.module.dsl.single val ROOM_MIGRATIONS = module { - single() + single() + single() } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationV5To6.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion5To6.kt similarity index 93% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationV5To6.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion5To6.kt index 2d492691..0752890a 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationV5To6.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion5To6.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.runBlocking import org.koin.core.qualifier.named import org.koin.core.scope.Scope -class RoomMigrationV5To6(val buildInfoProvider: BuildInfoProvider, val koinScope: Scope, val loggerFactory: ScanBridgeLoggerFactory) : +class RoomMigrationVersion5To6(val buildInfoProvider: BuildInfoProvider, val koinScope: Scope, val loggerFactory: ScanBridgeLoggerFactory) : Migration(5, 6) { private val logger = loggerFactory.withClass(this::class) 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 new file mode 100644 index 00000000..23e31fb9 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/migrations/RoomMigrationVersion6To7.kt @@ -0,0 +1,76 @@ +package io.github.chrisimx.scanbridge.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +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.savelastusedscansettings.D2RLastUsedScanSettingsMigrationSource +import kotlinx.coroutines.runBlocking +import org.koin.core.scope.Scope + +class RoomMigrationVersion6To7(val buildInfoProvider: BuildInfoProvider, val koinScope: Scope, val loggerFactory: ScanBridgeLoggerFactory) : + Migration(6, 7) { + + private val logger = loggerFactory.withClass(this::class) + + override fun migrate(connection: SQLiteConnection): Unit = runBlocking { + val (oldAppSettings, oldLastUsedScanSettings) = if (buildInfoProvider.platform == Platform.ANDROID) { + // On Android, we need to get the old AppSettings from the DataStore + logger.debug { "Android: Migration of old app settings and last used scan settings from DataStore to Room" } + val legacyAppSettingsDatasource = koinScope.get() + val legacyLastUsedScanSettingsDatasource = koinScope.get() + + legacyAppSettingsDatasource.getAppSettings() to legacyLastUsedScanSettingsDatasource.getLastUsedScanSettings() + } else { + logger.debug { + "Other Platform (not Android): Migration of old AppSettings and last used scan settings from DataStore to Room not necessary" + } + null to null + } + + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `appsettings` (`id` INTEGER NOT NULL, `writeDebugLogs` INTEGER NOT NULL, `disableCertValidation` INTEGER NOT NULL, `scanningResponseTimeoutInS` INTEGER NOT NULL, `chunkSizeForPDFExport` INTEGER NOT NULL, `rememberScanSettings` INTEGER NOT NULL, PRIMARY KEY(`id`))" + ) + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `lastusedscansettings` (`id` INTEGER NOT NULL, `lastUsedScanSettings` TEXT NOT NULL, `lastUsedScanSettingsEnterable` TEXT NOT NULL, PRIMARY KEY(`id`))" + ) + + oldAppSettings?.let { + logger.debug { "Inserting old app settings into Room" } + val appSettingsInsert = connection.prepare( + "INSERT INTO appsettings (id, writeDebugLogs, disableCertValidation, scanningResponseTimeoutInS, chunkSizeForPDFExport, rememberScanSettings) VALUES (?, ?, ?, ?, ?, ?)" + ) + appSettingsInsert.bindInt(1, it.id) + appSettingsInsert.bindBoolean(2, it.writeDebugLogs) + appSettingsInsert.bindBoolean(3, it.disableCertValidation) + appSettingsInsert.bindInt(4, it.scanningResponseTimeoutInS) + appSettingsInsert.bindInt(5, it.chunkSizeForPDFExport) + appSettingsInsert.bindBoolean(6, it.rememberScanSettings) + appSettingsInsert.step() + appSettingsInsert.close() + } + + oldLastUsedScanSettings?.let { + logger.debug { "Inserting old last used scan settings into Room" } + val lastUsedScanSettingsInsert = connection.prepare( + "INSERT INTO lastusedscansettings (id, lastUsedScanSettings, lastUsedScanSettingsEnterable) VALUES (?, ?, ?)" + ) + val scanSettingsJson = ScanSettingsJson.json + lastUsedScanSettingsInsert.bindInt(1, oldLastUsedScanSettings.id) + lastUsedScanSettingsInsert.bindText( + 2, + scanSettingsJson.encodeToString(oldLastUsedScanSettings.lastUsedScanSettings) + ) + lastUsedScanSettingsInsert.bindText( + 3, + scanSettingsJson.encodeToString(oldLastUsedScanSettings.lastUsedScanSettingsEnterable) + ) + lastUsedScanSettingsInsert.step() + lastUsedScanSettingsInsert.close() + } + } +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/D2RLastUsedScanSettingsMigrationSource.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/D2RLastUsedScanSettingsMigrationSource.kt new file mode 100644 index 00000000..f363dded --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/D2RLastUsedScanSettingsMigrationSource.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge.savelastusedscansettings + +import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings + +interface D2RLastUsedScanSettingsMigrationSource { + suspend fun getLastUsedScanSettings(): LastUsedScanSettings? +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/LastUsedScanSettingsRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/LastUsedScanSettingsRepository.kt new file mode 100644 index 00000000..61856748 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/LastUsedScanSettingsRepository.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.savelastusedscansettings + +import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings + +interface LastUsedScanSettingsRepository { + suspend fun getLastUsedScanSettings(): LastUsedScanSettings? + suspend fun setLastUsedScanSettings(scanSettings: LastUsedScanSettings?) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/RoomLastUsedScanSettingsRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/RoomLastUsedScanSettingsRepository.kt new file mode 100644 index 00000000..ccfaef47 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/RoomLastUsedScanSettingsRepository.kt @@ -0,0 +1,25 @@ +package io.github.chrisimx.scanbridge.savelastusedscansettings + +import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.LastUsedScanSettings + +class RoomLastUsedScanSettingsRepository(scanBridgeDb: ScanBridgeDb, private val appSettingsRepository: AppSettingsRepository) : + LastUsedScanSettingsRepository { + private val lastUsedScanSettingsDao = scanBridgeDb.lastUsedScanSettingsDao() + + override suspend fun getLastUsedScanSettings(): LastUsedScanSettings? { + val currentAppSettings = appSettingsRepository.getAppSettings() + if (!currentAppSettings.rememberScanSettings) return null + + return lastUsedScanSettingsDao.getLastUsedScanSettings() + } + + override suspend fun setLastUsedScanSettings(scanSettings: LastUsedScanSettings?) { + if (scanSettings != null) { + lastUsedScanSettingsDao.setLastUsedScanSettings(scanSettings) + } else { + lastUsedScanSettingsDao.clearLastUsedScanSettings() + } + } +}