From 3e49cfe72d769bd957f89993042596b810c063fd Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Mon, 10 Aug 2026 13:44:45 +0200 Subject: [PATCH 01/16] Port StartupScreen to Compose Multiplatform --- .../scanbridge/StartupTabDefinitions.kt | 65 ++++++++------ .../scanbridge/ScanBridgeApplication.kt | 4 + .../chrisimx/scanbridge/ScannerBrowser.kt | 2 +- .../dialog/CustomScannerDialog.kt | 2 +- .../scanbridge/StartupTabDefinitions.kt | 84 ++++++++++-------- composeUI/build.gradle.kts | 5 ++ .../drawable/baseline_image_24.xml | 12 +++ .../drawable/baseline_picture_as_pdf_24.xml | 12 +++ ...right.xml => baseline_rotate_right_24.xml} | 13 +-- .../drawable/favorite_24px.xml | 10 +++ .../composeResources/drawable/github_mark.xml | 5 ++ .../composeResources/drawable/ic_cyclone.xml | 12 --- .../drawable/ic_dark_mode.xml | 9 -- .../drawable/ic_light_mode.xml | 9 -- .../drawable/icon_about_dialog.png | Bin 0 -> 254536 bytes .../drawable/outline_crop_24.xml | 5 ++ .../drawable/outline_edit_24.xml | 5 ++ .../drawable/outline_error_24.xml | 5 ++ .../drawable/outline_file_save_24.xml | 5 ++ .../drawable/outline_pan_zoom_24.xml | 5 ++ .../drawable/outline_scan_24.xml | 5 ++ .../drawable/round_print_36.xml | 5 ++ .../drawable/rounded_adf_scanner_24.xml | 5 ++ .../drawable/rounded_content_copy_24.xml | 5 ++ .../drawable/rounded_document_scanner_24.xml | 5 ++ .../drawable/rounded_scanner_24.xml | 5 ++ .../drawable/rounded_warning_24.xml | 5 ++ .../drawable/twotone_wifi_find_24.xml | 9 ++ .../composeResources/values-de/strings.xml | 43 +++++++++ .../composeResources/values-it/strings.xml | 43 +++++++++ .../composeResources/values/strings.xml | 43 +++++++++ .../chrisimx/scanbridge/StartupScreen.kt | 46 +++++----- .../scanbridge/StartupTabsProvider.kt | 26 ++++++ .../scanbridge}/model/EditedCustomScanner.kt | 7 +- gradle/libs.versions.toml | 4 + 35 files changed, 398 insertions(+), 127 deletions(-) create mode 100644 composeUI/src/commonMain/composeResources/drawable/baseline_image_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/baseline_picture_as_pdf_24.xml rename composeUI/src/commonMain/composeResources/drawable/{ic_rotate_right.xml => baseline_rotate_right_24.xml} (80%) create mode 100644 composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/github_mark.xml delete mode 100644 composeUI/src/commonMain/composeResources/drawable/ic_cyclone.xml delete mode 100644 composeUI/src/commonMain/composeResources/drawable/ic_dark_mode.xml delete mode 100644 composeUI/src/commonMain/composeResources/drawable/ic_light_mode.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/icon_about_dialog.png create mode 100644 composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/outline_file_save_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/outline_pan_zoom_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/round_print_36.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/rounded_adf_scanner_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/rounded_content_copy_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/rounded_document_scanner_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml create mode 100644 composeUI/src/commonMain/composeResources/drawable/twotone_wifi_find_24.xml rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/StartupScreen.kt (78%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/StartupTabsProvider.kt rename {androidApp/src/main/java/io/github/chrisimx/scanbridge/data => core/src/commonMain/kotlin/io/github/chrisimx/scanbridge}/model/EditedCustomScanner.kt (51%) 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/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 680d8d22..634913a2 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -137,6 +137,10 @@ val appModule = module { single() bind NumberFormatter::class + single { + STARTUP_TABS_PROVIDER + } + includes(SCAN_PROTOCOLS, ROOM_MIGRATIONS, DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt index 43abed53..20d75ff3 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -43,7 +43,7 @@ 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.model.EditedCustomScanner import io.github.chrisimx.scanbridge.db.entities.CustomScanner import io.github.chrisimx.scanbridge.model.DiscoveredScanner import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index a4089256..05eab628 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt @@ -27,7 +27,7 @@ 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.scannerdiscovery.ProtocolWithExampleHandleString import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme import io.github.chrisimx.scanbridge.uicomponents.SelectionButtonRow 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/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index f7881737..b45b916e 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -35,6 +35,11 @@ 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.anyscanCore) api(project(":core")) 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..5ba3f980 --- /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..3c8202d2 --- /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 80% 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..a5c54680 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..862e50bd --- /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 0000000000000000000000000000000000000000..696b4a241fa7add51e2b7a065ef88f429e724f75 GIT binary patch literal 254536 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%0DVCB7v$_pB++ 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 d2tUPt+7UV#L*<SVm{(pDFq)h+- literal 0 HcmV?d00001 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..58ad9eb1 --- /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..54365925 --- /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..769755ce --- /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..a4a68640 --- /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..6bc993f8 --- /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..0c58c3f1 --- /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..69ab5333 --- /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..e3e3cc6f --- /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..edc172af --- /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..a28cf18e --- /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..23810b5f --- /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..575870bb --- /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..618f8874 --- /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..24f63b30 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -135,4 +135,47 @@ 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 \ 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..8cb26894 100644 --- a/composeUI/src/commonMain/composeResources/values-it/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-it/strings.xml @@ -133,4 +133,47 @@ 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 \ 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..1753d93d 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -137,4 +137,47 @@ 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 \ No newline at end of file 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index ebe9aade..b8a0e8f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ 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" timber = "5.0.1" @@ -31,6 +32,7 @@ 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" @@ -65,6 +67,7 @@ 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" } +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-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } @@ -93,6 +96,7 @@ 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" } From cec0614711bb3c49a39faeaa20fa5e7dd79520d8 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Mon, 10 Aug 2026 18:55:03 +0200 Subject: [PATCH 02/16] Port ScannerBrowser to Compose multiplatform --- androidApp/build.gradle.kts | 3 +- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 56 +---------- .../chrisimx/scanbridge/ScanningScreen.kt | 83 +++++++++++------ .../scanbridge/data/ui/ScanningScreenData.kt | 4 +- .../data/ui/ScanningScreenViewModel.kt | 4 +- .../chrisimx/scanbridge/SupportScreen.kt | 9 +- composeUI/build.gradle.kts | 7 ++ .../clipboard/ClipboardUtil.android.kt | 9 ++ .../clipboard/ClipboardUtil.apple.kt | 9 ++ .../drawable/baseline_image_24.xml | 2 +- .../drawable/baseline_picture_as_pdf_24.xml | 2 +- .../drawable/baseline_rotate_right_24.xml | 2 +- .../drawable/favorite_24px.xml | 2 +- .../drawable/outline_crop_24.xml | 2 +- .../drawable/outline_edit_24.xml | 2 +- .../drawable/outline_error_24.xml | 2 +- .../drawable/outline_file_save_24.xml | 2 +- .../drawable/outline_pan_zoom_24.xml | 2 +- .../drawable/outline_scan_24.xml | 2 +- .../drawable/round_print_36.xml | 2 +- .../drawable/rounded_adf_scanner_24.xml | 2 +- .../drawable/rounded_content_copy_24.xml | 2 +- .../drawable/rounded_document_scanner_24.xml | 2 +- .../drawable/rounded_scanner_24.xml | 2 +- .../drawable/rounded_warning_24.xml | 2 +- .../drawable/twotone_wifi_find_24.xml | 6 +- .../composeResources/values-de/strings.xml | 1 + .../composeResources/values-it/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../chrisimx/scanbridge/ScanBridgeRoutes.kt | 55 +++++++++++ .../chrisimx/scanbridge/ScannerBrowser.kt | 85 ++++++++++------- .../scanbridge/clipboard/ClipboardUtil.kt | 5 + .../uicomponents/FoundScannerItem.kt | 44 +++++---- .../uicomponents/FullScreenError.kt | 39 +++++--- .../dialog/CustomScannerDialog.kt | 93 +++++++++---------- .../uicomponents/dialog/DeleteDialog.kt | 18 ++-- .../scanbridge/clipboard/ClipboardUtil.jvm.kt | 9 ++ .../scanbridge/model/UrlValidationResult.kt | 9 ++ .../ScannerDiscoveryScreenViewModel.kt | 64 ++++++++++++- gradle/libs.versions.toml | 2 +- 40 files changed, 415 insertions(+), 233 deletions(-) create mode 100644 composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.android.kt create mode 100644 composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.apple.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeRoutes.kt rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/ScannerBrowser.kt (73%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.kt rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt (78%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt (71%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt (70%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/dialog/DeleteDialog.kt (84%) create mode 100644 composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/clipboard/ClipboardUtil.jvm.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/UrlValidationResult.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index be1b92a6..22686581 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -106,14 +106,13 @@ dependencies { 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) 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 88fb10e3..7f871c08 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -23,7 +23,6 @@ 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 @@ -35,61 +34,12 @@ import io.github.chrisimx.scanbridge.uicomponents.TemporaryFileHandler import io.github.chrisimx.scanbridge.util.doTempFilesExist 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 scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.outline_error_24 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 - } -} - @Composable fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { val context = LocalContext.current @@ -105,7 +55,7 @@ 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) diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index da283dbe..b15d4e6b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -86,8 +86,6 @@ import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -117,10 +115,35 @@ 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.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource import org.koin.androidx.compose.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.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.rounded_document_scanner_24 +import scanbridge.composeui.generated.resources.save_to_file +import scanbridge.composeui.generated.resources.scan +import scanbridge.composeui.generated.resources.scannercapabilities_retrieve_error +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 scanbridge.composeui.generated.resources.twotone_wifi_find_24 import timber.log.Timber private const val TAG = "ScanningScreen" @@ -143,7 +166,7 @@ fun ScanningScreenBottomBar( ) } ) { - Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings)) + Icon(Icons.Filled.Settings, contentDescription = stringResource(Res.string.settings)) } IconButton( onClick = { @@ -160,8 +183,8 @@ fun ScanningScreenBottomBar( } ) { Icon( - painterResource(R.drawable.outline_file_save_24), - contentDescription = stringResource(R.string.save_to_file) + painterResource(Res.drawable.outline_file_save_24), + contentDescription = stringResource(Res.string.save_to_file) ) } IconButton( @@ -180,7 +203,7 @@ fun ScanningScreenBottomBar( ) { Icon( Icons.Filled.Share, - contentDescription = stringResource(R.string.export) + contentDescription = stringResource(Res.string.export) ) } }, @@ -220,11 +243,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 +260,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) } ) } @@ -257,11 +280,11 @@ fun ScanningScreenBottomBar( 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)) } ) } } @@ -373,9 +396,9 @@ fun ScanningScreen( ) { val errorDescription = scanningViewModel.scanningScreenData.error FullScreenError( - errorDescription?.icon ?: R.drawable.twotone_wifi_find_24, + errorDescription?.icon ?: Res.drawable.twotone_wifi_find_24, stringResource( - errorDescription?.pretext ?: R.string.scannercapabilities_retrieve_error, + errorDescription?.pretext ?: Res.string.scannercapabilities_retrieve_error, errorDescription?.text ?: "Error text not found" ), copyButton = true @@ -607,8 +630,8 @@ fun ScanningScreen( if (scanningViewModel.scanningScreenData.confirmPageDeleteDialogShown) { DeletionDialog( - R.string.delete_current_page, - R.string.page_deletion_confirmation, + Res.string.delete_current_page, + Res.string.page_deletion_confirmation, onDismiss = { scanningViewModel.setDeletePageDialogShown(false) }, onConfirmed = { Timber.d("Deleting page") @@ -665,7 +688,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 +712,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 +730,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") ) @@ -741,7 +764,7 @@ fun ScanContent( Icon( Icons.Outlined.Delete, contentDescription = stringResource( - R.string.delete_current_page + Res.string.delete_current_page ) ) } @@ -763,7 +786,7 @@ fun ScanContent( Icon( Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource( - R.string.swap_with_previous_page + Res.string.swap_with_previous_page ) ) } @@ -780,9 +803,9 @@ fun ScanContent( } }) { Icon( - painterResource(R.drawable.outline_crop_24), + painterResource(Res.drawable.outline_crop_24), contentDescription = stringResource( - R.string.crop + Res.string.crop ) ) } @@ -804,7 +827,7 @@ fun ScanContent( Icon( Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = stringResource( - R.string.swap_with_next_page + Res.string.swap_with_next_page ) ) } @@ -814,9 +837,9 @@ fun ScanContent( } }) { Icon( - painterResource(R.drawable.baseline_rotate_right_24), + painterResource(Res.drawable.baseline_rotate_right_24), contentDescription = stringResource( - R.string.rotate_right + Res.string.rotate_right ) ) } @@ -836,6 +859,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/data/ui/ScanningScreenData.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index f71f678b..be1c4f4b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -27,8 +27,10 @@ import io.github.chrisimx.anyscan.CommonScannerCapabilities import io.github.chrisimx.scanbridge.ScanSettingsComposableStateHolder import java.io.File import kotlin.uuid.Uuid +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource -data class ErrorDescription(val pretext: Int?, val icon: Int?, val text: String?) +data class ErrorDescription(val pretext: StringResource?, val icon: DrawableResource?, val text: String?) data class ScanningScreenData( val sessionID: Uuid, 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 f1569f03..2bd6404d 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 @@ -90,6 +90,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.scope.Scope import timber.log.Timber @@ -223,7 +225,7 @@ class ScanningScreenViewModel( _scanningScreenData.confirmPageDeleteDialogShown.value = value } - fun setError(error: String?, titleResource: Int? = null, errorIcon: Int? = null) { + fun setError(error: String?, titleResource: StringResource? = null, errorIcon: DrawableResource? = null) { _scanningScreenData.error.value = ErrorDescription( titleResource, errorIcon, 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/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index b45b916e..c68d2fc1 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.android.kmp.library) + alias(libs.plugins.kotlin.serialization) } kotlin { @@ -27,6 +28,9 @@ kotlin { } } + iosArm64() + iosSimulatorArm64() + sourceSets { commonMain.dependencies { api(libs.compose.runtime) @@ -41,6 +45,9 @@ kotlin { api(libs.koin.compose.viewmodel) api(libs.koin.core) + api(libs.coil.compose) + api(libs.coil.fetcher.ktor) + api(libs.anyscanCore) api(project(":core")) } 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/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/commonMain/composeResources/drawable/baseline_image_24.xml b/composeUI/src/commonMain/composeResources/drawable/baseline_image_24.xml index 5ba3f980..a5e7e7ec 100644 --- a/composeUI/src/commonMain/composeResources/drawable/baseline_image_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/baseline_image_24.xml @@ -6,7 +6,7 @@ android:width="24dp"> 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 index 3c8202d2..1b64bb32 100644 --- a/composeUI/src/commonMain/composeResources/drawable/baseline_picture_as_pdf_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/baseline_picture_as_pdf_24.xml @@ -6,7 +6,7 @@ android:width="24dp"> diff --git a/composeUI/src/commonMain/composeResources/drawable/baseline_rotate_right_24.xml b/composeUI/src/commonMain/composeResources/drawable/baseline_rotate_right_24.xml index a5c54680..3638c3c2 100644 --- a/composeUI/src/commonMain/composeResources/drawable/baseline_rotate_right_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/baseline_rotate_right_24.xml @@ -7,7 +7,7 @@ android:width="24dp"> diff --git a/composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml b/composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml index 862e50bd..68e5cb25 100644 --- a/composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml +++ b/composeUI/src/commonMain/composeResources/drawable/favorite_24px.xml @@ -5,6 +5,6 @@ android:viewportHeight="960" android:tint="#D30E40"> diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml index 58ad9eb1..e4c303ea 100644 --- a/composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/outline_crop_24.xml @@ -1,5 +1,5 @@ - + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml index 54365925..da3f216c 100644 --- a/composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/outline_edit_24.xml @@ -1,5 +1,5 @@ - + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml index 769755ce..dd265468 100644 --- a/composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/outline_error_24.xml @@ -1,5 +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 index a4a68640..6aace552 100644 --- a/composeUI/src/commonMain/composeResources/drawable/outline_file_save_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/outline_file_save_24.xml @@ -1,5 +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 index 6bc993f8..bd87659a 100644 --- a/composeUI/src/commonMain/composeResources/drawable/outline_pan_zoom_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/outline_pan_zoom_24.xml @@ -1,5 +1,5 @@ - + diff --git a/composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml b/composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml index 0c58c3f1..5b99ae3f 100644 --- a/composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/outline_scan_24.xml @@ -1,5 +1,5 @@ - + diff --git a/composeUI/src/commonMain/composeResources/drawable/round_print_36.xml b/composeUI/src/commonMain/composeResources/drawable/round_print_36.xml index 69ab5333..c6bfeea8 100644 --- a/composeUI/src/commonMain/composeResources/drawable/round_print_36.xml +++ b/composeUI/src/commonMain/composeResources/drawable/round_print_36.xml @@ -1,5 +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 index e3e3cc6f..f63b777a 100644 --- a/composeUI/src/commonMain/composeResources/drawable/rounded_adf_scanner_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_adf_scanner_24.xml @@ -1,5 +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 index edc172af..e8ad0c0f 100644 --- a/composeUI/src/commonMain/composeResources/drawable/rounded_content_copy_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_content_copy_24.xml @@ -1,5 +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 index a28cf18e..2e6f290e 100644 --- a/composeUI/src/commonMain/composeResources/drawable/rounded_document_scanner_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_document_scanner_24.xml @@ -1,5 +1,5 @@ - + diff --git a/composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml b/composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml index 23810b5f..5a98be4e 100644 --- a/composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_scanner_24.xml @@ -1,5 +1,5 @@ - + diff --git a/composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml b/composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml index 575870bb..d1b69eb5 100644 --- a/composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/rounded_warning_24.xml @@ -1,5 +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 index 618f8874..fe0ba91c 100644 --- a/composeUI/src/commonMain/composeResources/drawable/twotone_wifi_find_24.xml +++ b/composeUI/src/commonMain/composeResources/drawable/twotone_wifi_find_24.xml @@ -1,9 +1,9 @@ - + - + - + diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index 24f63b30..868834ee 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -178,4 +178,5 @@ 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 \ 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 8cb26894..56a41f09 100644 --- a/composeUI/src/commonMain/composeResources/values-it/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-it/strings.xml @@ -176,4 +176,5 @@ 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 \ 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 1753d93d..405a1630 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -65,6 +65,7 @@ Rotate right Rotating… Access custom scanner + URL Just connect URL (eSCL resource) Connect to a custom scanner 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/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 20d75ff3..19ede559 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,17 +34,17 @@ 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.model.EditedCustomScanner import io.github.chrisimx.scanbridge.db.entities.CustomScanner import io.github.chrisimx.scanbridge.model.DiscoveredScanner +import io.github.chrisimx.scanbridge.model.UrlValidationResult import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenViewModel import io.github.chrisimx.scanbridge.uicomponents.FoundScannerItem import io.github.chrisimx.scanbridge.uicomponents.FullScreenError @@ -54,8 +53,21 @@ 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 kotlinx.coroutines.runBlocking +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 +101,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 +135,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 +159,8 @@ fun ScannerBrowser( var deletionScheduledScanner: Uuid? by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + AnimatedContent( targetState = discoveredScanners.isNotEmpty() || customScanners.isNotEmpty(), label = "ScannerList" @@ -164,8 +178,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 +187,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 +198,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/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/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 78% 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..7fb0b728 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 @@ -41,25 +41,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.ports.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 +84,7 @@ fun tintedPainterResource(id: Int, tint: Color): Painter { } } +@OptIn(ExperimentalUuidApi::class) @Composable fun FoundScannerItem( scannerHandleString: String, @@ -86,8 +93,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 +122,7 @@ fun FoundScannerItem( verticalAlignment = Alignment.CenterVertically ) { val tintedPlaceholder = tintedPainterResource( - R.drawable.round_print_36, + Res.drawable.round_print_36, MaterialTheme.colorScheme.surfaceTint ) @@ -123,7 +133,7 @@ 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) @@ -142,9 +152,9 @@ fun FoundScannerItem( modifier = Modifier .align(Alignment.CenterVertically) .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 +180,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/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 70% 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 05eab628..03a011b7 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 @@ -20,31 +20,47 @@ 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.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 +73,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 +96,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 +112,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 +144,9 @@ fun CustomScannerDialog( if (isNewScanner) { Button( onClick = { - val url = validateUrl() ?: return@Button onConnectClicked( nameText, - url, + urlText, selectedProtocol.protocolIdentifier, true, true @@ -156,14 +154,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 +168,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 +183,7 @@ fun CustomScannerDialog( }, modifier = Modifier.padding(top = 16.dp).testTag("editcustomscanner") ) { - Text(stringResource(R.string.save)) + Text(stringResource(Res.string.save)) } } } @@ -195,7 +191,8 @@ fun CustomScannerDialog( } } -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + +@Suppress("UnusedMaterial3ScaffoldPaddingParameter") @Preview @Composable fun PreviewCustomScannerDialog() { @@ -207,8 +204,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/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/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/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/ScannerDiscoveryScreenViewModel.kt index 2af43708..5fc2ac7f 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,12 @@ 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.model.EditedCustomScanner +import io.github.chrisimx.scanbridge.model.UrlValidationResult import io.github.chrisimx.scanbridge.ports.CustomScannerRepository +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager +import io.ktor.http.Url import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import kotlinx.coroutines.flow.SharingStarted @@ -17,8 +21,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 +46,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/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8a0e8f7..10d6dfbb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -compose-multiplatform = "1.10.3" +compose-multiplatform = "1.11.1" androidx-activityCompose = "1.12.0" agp = "9.1.0" From a5853131baa0c4d9d6a1e121e5b3b697c76e71cb Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Mon, 10 Aug 2026 20:09:44 +0200 Subject: [PATCH 03/16] Port ScanSettings and AppSettings to Compose multiplatform --- androidApp/build.gradle.kts | 5 +- .../chrisimx/scanbridge/ScanningScreen.kt | 2 +- .../scanbridge/util/ESCLKtExtensions.kt | 58 ------------- composeUI/build.gradle.kts | 1 + .../composeResources/values-de/strings.xml | 2 + .../composeResources/values-it/strings.xml | 2 + .../composeResources/values/strings.xml | 2 + .../chrisimx/scanbridge/AppSettingsScreen.kt | 85 +++++++------------ .../chrisimx/scanbridge/ScanSettings.kt | 32 ++++--- .../chrisimx/scanbridge/ScannerBrowser.kt | 5 +- .../uicomponents/SizeBasedConditionalView.kt | 0 .../scanbridge/uicomponents/TitledCard.kt | 0 .../uicomponents/dialog/SimpleTextDialog.kt | 0 .../uicomponents/settings/CheckboxSetting.kt | 0 .../settings/MoreInformationButton.kt | 7 +- .../uicomponents/settings/UIntSetting.kt | 12 ++- .../settings/VersionComposable.kt | 27 +++--- .../JobStateLocalization.kt | 20 +++++ .../ScanSettingsChoiceLocalization.kt | 3 +- .../ScannerConceptLocalization.kt | 2 +- .../UIInputSourceTypeLocalization.kt | 16 ++++ gradle/libs.versions.toml | 4 +- gradle/wrapper/gradle-wrapper.properties | 3 +- 23 files changed, 133 insertions(+), 155 deletions(-) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/AppSettingsScreen.kt (82%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/ScanSettings.kt (88%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/SizeBasedConditionalView.kt (100%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/TitledCard.kt (100%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/dialog/SimpleTextDialog.kt (100%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/settings/CheckboxSetting.kt (100%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/settings/MoreInformationButton.kt (83%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt (91%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt (75%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/JobStateLocalization.kt rename composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/{ => localizationhelper}/ScanSettingsChoiceLocalization.kt (97%) rename composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/{ => localizationhelper}/ScannerConceptLocalization.kt (91%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/UIInputSourceTypeLocalization.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 22686581..624bf535 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -31,7 +31,7 @@ tasks.withType().all { android { namespace = "io.github.chrisimx.scanbridge" - compileSdk = 36 + compileSdk = 37 androidResources { generateLocaleConfig = true @@ -40,7 +40,7 @@ android { defaultConfig { applicationId = "io.github.chrisimx.scanbridge" minSdk = 28 - targetSdk = 36 + targetSdk = 37 versionCode = 2_001_005 // we just count up for each build versionName = "2.2.0-alpha1" @@ -131,7 +131,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/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index b15d4e6b..15f7720e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -106,7 +106,7 @@ import io.github.chrisimx.scanbridge.util.SnackbarType import io.github.chrisimx.scanbridge.util.clearAndNavigateTo import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage -import io.github.chrisimx.scanbridge.util.toReadableString +import io.github.chrisimx.scanbridge.util.localizationhelper.toReadableString import java.io.File import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope 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/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index c68d2fc1..a2dde8b8 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -44,6 +44,7 @@ kotlin { api(libs.koin.compose) api(libs.koin.compose.viewmodel) api(libs.koin.core) + api(libs.constraintlayout.compose.multiplatform) api(libs.coil.compose) api(libs.coil.fetcher.ktor) diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index 868834ee..37cf8caf 100644 --- a/composeUI/src/commonMain/composeResources/values-de/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-de/strings.xml @@ -179,4 +179,6 @@ Account erfolgreich erstellt E-Mail URL + Information zur Einstellung + iOS \ 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 56a41f09..4d2233e0 100644 --- a/composeUI/src/commonMain/composeResources/values-it/strings.xml +++ b/composeUI/src/commonMain/composeResources/values-it/strings.xml @@ -177,4 +177,6 @@ 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 \ 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 405a1630..417b9524 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -181,4 +181,6 @@ Open support website Sign-up to Fireamp Support Email + Detailed information regarding the setting + iOS \ 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/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanSettings.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettings.kt similarity index 88% 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..fa5bce72 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.util.localizationhelper.toLocalizedName +import io.github.chrisimx.scanbridge.util.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/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerBrowser.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerBrowser.kt index 19ede559..3c6ea8dc 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerBrowser.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerBrowser.kt @@ -41,20 +41,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import io.github.chrisimx.scanbridge.model.EditedCustomScanner import io.github.chrisimx.scanbridge.db.entities.CustomScanner import io.github.chrisimx.scanbridge.model.DiscoveredScanner -import io.github.chrisimx.scanbridge.model.UrlValidationResult +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 kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel 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/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/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..6ecd4242 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.ports.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 75% 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..33c2471b 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,30 @@ 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.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) } @Composable @@ -55,12 +62,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 +88,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/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/JobStateLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/JobStateLocalization.kt new file mode 100644 index 00000000..0707b98c --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/JobStateLocalization.kt @@ -0,0 +1,20 @@ +package io.github.chrisimx.scanbridge.util.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/util/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/util/localizationhelper/ScanSettingsChoiceLocalization.kt index 8843a48b..c9cf712c 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanSettingsChoiceLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/ScanSettingsChoiceLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util +package io.github.chrisimx.scanbridge.util.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/util/localizationhelper/ScannerConceptLocalization.kt similarity index 91% rename from composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/ScannerConceptLocalization.kt index 43c0cd74..192e7e37 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScannerConceptLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/ScannerConceptLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util +package io.github.chrisimx.scanbridge.util.localizationhelper import androidx.compose.runtime.Composable import io.github.chrisimx.anyscan.ScannerConcept diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/UIInputSourceTypeLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/UIInputSourceTypeLocalization.kt new file mode 100644 index 00000000..0638ef6c --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/UIInputSourceTypeLocalization.kt @@ -0,0 +1,16 @@ +package io.github.chrisimx.scanbridge.util.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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10d6dfbb..f2884b03 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,10 +3,11 @@ 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" @@ -67,6 +68,7 @@ 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" } 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 From b58a0f034756e3d5e0d6154cb1b8478298ab3620 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 11 Aug 2026 00:33:38 +0200 Subject: [PATCH 04/16] Port CropScreen to Compose multiplatform --- androidApp/build.gradle.kts | 2 - ...CrashHandler.kt => AndroidCrashHandler.kt} | 2 +- .../chrisimx/scanbridge/CrashActivity.kt | 59 +------ .../github/chrisimx/scanbridge/CropScreen.kt | 160 ------------------ .../scanbridge/ScanBridgeApplication.kt | 13 +- .../chrisimx/scanbridge/ScanningScreen.kt | 2 +- .../scanbridge/data/ui/ScanningScreenData.kt | 4 +- .../data/ui/ScanningScreenViewModel.kt | 19 ++- .../DatastoreShownMessagesRepository.kt | 6 +- .../chrisimx/scanbridge/util/ImageUtil.kt | 16 -- .../scanbridge/util/ScanFileNameUtil.kt | 9 - composeUI/build.gradle.kts | 1 + .../PlatformBackHandler.android.kt | 9 + .../PlatformBackHandler.apple.kt | 11 ++ .../chrisimx/scanbridge/CrashDisplay.kt | 106 ++++++++++++ .../github/chrisimx/scanbridge/CropScreen.kt | 136 +++++++++++++++ .../chrisimx/scanbridge/ScanSettings.kt | 4 +- .../JobStateLocalization.kt | 2 +- .../ScanSettingsChoiceLocalization.kt | 2 +- .../ScannerConceptLocalization.kt | 2 +- .../UIInputSourceTypeLocalization.kt | 2 +- .../platformhelper/PlatformBackHandler.kt | 9 + .../scanbridge/uicomponents/CroppableImage.kt | 17 +- .../uicomponents/dialog/LoadingDialog.kt | 5 +- .../util/NavControllerExtensions.kt | 0 .../platformhelper/PlatformBackHandler.jvm.kt | 8 + .../cropfeature/AndroidImageCropService.kt | 50 ++++++ .../cropfeature/CropScreenViewModel.kt | 75 ++++++++ .../cropfeature/FinishCropUseCase.kt | 52 ++++++ .../cropfeature/ImageCropService.kt | 12 ++ .../scanbridge/db/daos/ScannedPageDao.kt | 3 + .../scanbridge/filesystem/FileSystem.kt | 10 ++ .../filesystem/KotlinIOFileSystem.kt | 13 ++ .../github/chrisimx/scanbridge/model/Rect.kt | 8 + .../scanbridge/model/ScanBridgeFile.kt | 12 +- .../scanbridge/util/ScanFileNameUtil.kt | 11 ++ gradle/libs.versions.toml | 2 + 37 files changed, 578 insertions(+), 276 deletions(-) rename androidApp/src/main/java/io/github/chrisimx/scanbridge/{CrashHandler.kt => AndroidCrashHandler.kt} (95%) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt create mode 100644 composeUI/src/androidMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.android.kt create mode 100644 composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.apple.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt rename composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{util => }/localizationhelper/JobStateLocalization.kt (94%) rename composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{util => }/localizationhelper/ScanSettingsChoiceLocalization.kt (97%) rename composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{util => }/localizationhelper/ScannerConceptLocalization.kt (91%) rename composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{util => }/localizationhelper/UIInputSourceTypeLocalization.kt (89%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.kt rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt (97%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/dialog/LoadingDialog.kt (93%) rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt (100%) create mode 100644 composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.jvm.kt create mode 100644 core/src/androidMain/kotlin/cropfeature/AndroidImageCropService.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/CropScreenViewModel.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/ImageCropService.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileSystem.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/Rect.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 624bf535..e10c8088 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -104,7 +104,6 @@ dependencies { implementation(libs.koin.annotations) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.androix.navigation) - implementation(libs.zoomable) implementation(libs.kotlin.reflect) implementation(libs.timber) implementation(libs.androidx.navigation.compose) @@ -113,7 +112,6 @@ dependencies { implementation(project(":composeUI")) implementation(libs.androidx.concurrent.futures) - ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.runtime) implementation(libs.androidx.sqlite.bundled) 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 95% 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..61f4716f 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt @@ -27,7 +27,7 @@ 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/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/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 634913a2..ef259996 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -8,6 +8,7 @@ import android.app.Application import android.content.Context import coil3.ImageLoader import coil3.network.ktor3.KtorNetworkFetcherFactory +import cropfeature.AndroidImageCropService import io.github.chrisimx.localization.JvmNumberFormatter import io.github.chrisimx.scanbridge.adapters.KoinBasedScanningProtocolManager import io.github.chrisimx.scanbridge.adapters.RoomBackedCustomScannerRepository @@ -15,12 +16,17 @@ 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.cropfeature.CropScreenViewModel +import io.github.chrisimx.scanbridge.cropfeature.FinishCropUseCase +import io.github.chrisimx.scanbridge.cropfeature.ImageCropService 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.db.ScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.migrations.ROOM_MIGRATIONS +import io.github.chrisimx.scanbridge.filesystem.FileSystem +import io.github.chrisimx.scanbridge.filesystem.KotlinIOFileSystem import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.initialscansettings.DefaultInitialScanSettingsProvider import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider @@ -92,7 +98,7 @@ fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): .build() } val appModule = module { - single() bind Thread.UncaughtExceptionHandler::class + single() bind Thread.UncaughtExceptionHandler::class single() bind LocaleProvider::class single() bind AppSettingsRepository::class single() bind DebugLogService::class @@ -141,6 +147,11 @@ val appModule = module { STARTUP_TABS_PROVIDER } + viewModel() + single() bind ImageCropService::class + single() + single() bind FileSystem::class + includes(SCAN_PROTOCOLS, ROOM_MIGRATIONS, DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 15f7720e..40e49872 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -106,7 +106,7 @@ 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.localizationhelper.toReadableString +import io.github.chrisimx.scanbridge.localizationhelper.toReadableString import java.io.File import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index be1c4f4b..a78b4454 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -44,7 +44,7 @@ data class ScanningScreenData( val showSaveOptions: MutableState = mutableStateOf(false), val exportOptionsPopupPosition: MutableState?> = mutableStateOf(null), val savePopupPosition: MutableState?> = mutableStateOf(null), - val stateProgressStringRes: MutableState = mutableStateOf(null), + val stateProgressStringRes: MutableState = mutableStateOf(null), val sourceFileToSave: MutableState = mutableStateOf(null), val isRotating: MutableState = mutableStateOf(false) ) { @@ -78,7 +78,7 @@ data class ImmutableScanningScreenData( private val showSaveOptionsState: State, private val exportOptionsPopupPositionState: State?>, private val saveOptionsPopupPositionState: State?>, - private val progressStringResState: State, + private val progressStringResState: State, private val sourceFileToSaveState: State, private val isRotatingState: State ) { diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 2bd6404d..3277dbe7 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 @@ -53,6 +53,7 @@ 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.ScanBridgeFile import io.github.chrisimx.scanbridge.model.ScanRelativeRotation import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.model.ScannerHandle @@ -83,7 +84,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 @@ -94,6 +94,9 @@ import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.scope.Scope +import scanbridge.composeui.generated.resources.Res +import scanbridge.composeui.generated.resources.exporting +import scanbridge.composeui.generated.resources.rotating_page import timber.log.Timber enum class ScanningScreenEvent { @@ -209,7 +212,7 @@ class ScanningScreenViewModel( _scanningScreenData.savePopupPosition.value = Triple(x, y, height) } - fun setLoadingText(stringRes: Int?) { + fun setLoadingText(stringRes: StringResource?) { _scanningScreenData.stateProgressStringRes.value = stringRes } @@ -245,11 +248,11 @@ class ScanningScreenViewModel( return } _scanningScreenData.isRotating.value = true - setLoadingText(R.string.rotating_page) + setLoadingText(Res.string.rotating_page) try { val pagePath = scannedPage.filePath - val pageFile = File(pagePath) + val pageScanBridgeFile = ScanBridgeFile(kotlinx.io.files.Path(pagePath)) Timber.d("Decoding $pagePath") val originalBitmap = withContext(Dispatchers.IO) { BitmapFactory.decodeFile(pagePath) } @@ -263,7 +266,7 @@ class ScanningScreenViewModel( val rotatedBitmap = withContext(Dispatchers.IO) { originalBitmap.rotateBy90() } originalBitmap.recycle() - val editedImageName = pageFile.getEditedImageName() + val editedImageName = pageScanBridgeFile.getEditedImageName() val newFile = File(application.filesDir, editedImageName) Timber.d("Saving rotated $pagePath") @@ -504,7 +507,7 @@ class ScanningScreenViewModel( return } - setLoadingText(R.string.exporting) + setLoadingText(Res.string.exporting) val parentDir = File(application.filesDir, "exports") if (!parentDir.exists()) { @@ -645,7 +648,7 @@ class ScanningScreenViewModel( return } - setLoadingText(R.string.exporting) + setLoadingText(Res.string.exporting) val parentDir = File(application.filesDir, "exports") if (!parentDir.exists()) { @@ -667,7 +670,7 @@ class ScanningScreenViewModel( zipOutputFile, { counter++ - // TODO: This will never work correctly because extension is always empty + // TODO: This will never work correctly because extension is always empty. Use outputName instead "scan-${counter.toString().padStart(digitsNeeded, '0')}.${it.extension}" } ) 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/util/ImageUtil.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ImageUtil.kt index 08f3efa3..0fa5b3ae 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 @@ -11,22 +11,6 @@ fun Bitmap.rotateBy90(): Bitmap { 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/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index a2dde8b8..319490eb 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { api(libs.koin.compose.viewmodel) api(libs.koin.core) api(libs.constraintlayout.compose.multiplatform) + api(libs.zoomable) api(libs.coil.compose) api(libs.coil.fetcher.ktor) 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/platformhelper/PlatformBackHandler.apple.kt b/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.apple.kt new file mode 100644 index 00000000..064f5f49 --- /dev/null +++ b/composeUI/src/appleMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.apple.kt @@ -0,0 +1,11 @@ +package io.github.chrisimx.scanbridge.platformhelper + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.backhandler.BackHandler + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) { + BackHandler(enabled, onBack) +} 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..5c4036b0 --- /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.ports.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..0f2622b1 --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt @@ -0,0 +1,136 @@ +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() + + PlatformBackHandler { + navController.clearAndNavigateTo(returnRoute) + } + + LaunchedEffect(cropState) { + if (cropState is CropState.Finished) { + navController.clearAndNavigateTo(returnRoute) + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + 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/ScanSettings.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettings.kt index fa5bce72..67ebf9b9 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettings.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettings.kt @@ -55,8 +55,8 @@ 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.localizationhelper.toLocalizedName -import io.github.chrisimx.scanbridge.util.localizationhelper.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 diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/JobStateLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/JobStateLocalization.kt similarity index 94% rename from composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/JobStateLocalization.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/JobStateLocalization.kt index 0707b98c..56d5ddaf 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/JobStateLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/JobStateLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util.localizationhelper +package io.github.chrisimx.scanbridge.localizationhelper import io.github.chrisimx.esclkt.JobState import org.jetbrains.compose.resources.getString diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/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/localizationhelper/ScanSettingsChoiceLocalization.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScanSettingsChoiceLocalization.kt index c9cf712c..c2b7f2c7 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/ScanSettingsChoiceLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScanSettingsChoiceLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util.localizationhelper +package io.github.chrisimx.scanbridge.localizationhelper import androidx.compose.runtime.Composable import io.github.chrisimx.anyscan.ColorMode diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/ScannerConceptLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScannerConceptLocalization.kt similarity index 91% rename from composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/ScannerConceptLocalization.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScannerConceptLocalization.kt index 192e7e37..90b45c78 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/ScannerConceptLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/ScannerConceptLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util.localizationhelper +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/util/localizationhelper/UIInputSourceTypeLocalization.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/UIInputSourceTypeLocalization.kt similarity index 89% rename from composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/UIInputSourceTypeLocalization.kt rename to composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/UIInputSourceTypeLocalization.kt index 0638ef6c..db009ca7 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/localizationhelper/UIInputSourceTypeLocalization.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/localizationhelper/UIInputSourceTypeLocalization.kt @@ -1,4 +1,4 @@ -package io.github.chrisimx.scanbridge.util.localizationhelper +package io.github.chrisimx.scanbridge.localizationhelper import androidx.compose.runtime.Composable import io.github.chrisimx.scanbridge.model.UIInputSourceType 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/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/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/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..feaa3caf --- /dev/null +++ b/composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/platformhelper/PlatformBackHandler.jvm.kt @@ -0,0 +1,8 @@ +package io.github.chrisimx.scanbridge.platformhelper + +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) { + +} diff --git a/core/src/androidMain/kotlin/cropfeature/AndroidImageCropService.kt b/core/src/androidMain/kotlin/cropfeature/AndroidImageCropService.kt new file mode 100644 index 00000000..0285e76d --- /dev/null +++ b/core/src/androidMain/kotlin/cropfeature/AndroidImageCropService.kt @@ -0,0 +1,50 @@ +package cropfeature + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import io.github.chrisimx.scanbridge.cropfeature.ImageCropService +import io.github.chrisimx.scanbridge.model.Rect +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import java.io.File +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AndroidImageCropService( + loggerFactory: ScanBridgeLoggerFactory +) : ImageCropService { + private val logger = loggerFactory.withClass(this::class) + + override suspend fun crop(sourcePath: ScanBridgeFile, outputPath: ScanBridgeFile, cropRect: Rect): Boolean = withContext(Dispatchers.IO) { + val pathString = sourcePath.path.toString() + val sourceBitmap = BitmapFactory.decodeFile(pathString) + 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 croppedFile = File(outputPath.path.toString()) + croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, croppedFile.outputStream()) + croppedBitmap.recycle() + + return@withContext true + } +} 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..a5f45e48 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt @@ -0,0 +1,52 @@ +package io.github.chrisimx.scanbridge.cropfeature + +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.FileSystem +import io.github.chrisimx.scanbridge.model.Rect +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.chrisimx.scanbridge.util.getEditedImageName + +class FinishCropUseCase( + private val imageCropService: ImageCropService, + scanBridgeDb: ScanBridgeDb, + private val fileSystem: FileSystem +) { + val scannedPageDao = scanBridgeDb.scannedPageDao() + + suspend operator fun invoke( + page: ScannedPage, + cropRect: Rect, + ): Boolean { + val scanFile = ScanBridgeFile(page.filePath) + + // Determine an output for the cropped image + val editFileName = scanFile.getEditedImageName() + val editFile = scanFile + .parent() + ?.child(editFileName) + ?: return false + + val successfulCrop = imageCropService.crop( + sourcePath = scanFile, + outputPath = editFile, + cropRect = cropRect, + ) + + if (!successfulCrop) { + return false + } + + scannedPageDao.update( + page.copy( + filePath = editFile.path.toString() + ) + ) + + // Delete the original file + fileSystem.delete(scanFile) + + 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..4460918a --- /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.chrisimx.scanbridge.model.ScanBridgeFile + +interface ImageCropService { + suspend fun crop( + sourcePath: ScanBridgeFile, + outputPath: ScanBridgeFile, + cropRect: Rect, + ): Boolean +} 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/filesystem/FileSystem.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileSystem.kt new file mode 100644 index 00000000..3034a302 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileSystem.kt @@ -0,0 +1,10 @@ +package io.github.chrisimx.scanbridge.filesystem + +import io.github.chrisimx.scanbridge.model.ScanBridgeFile + +interface FileSystem { + suspend fun delete( + file: ScanBridgeFile, + mustExist: Boolean = true + ) +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt new file mode 100644 index 00000000..f5e65468 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt @@ -0,0 +1,13 @@ +package io.github.chrisimx.scanbridge.filesystem + +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlinx.io.files.SystemFileSystem + +class KotlinIOFileSystem : FileSystem { + override suspend fun delete(file: ScanBridgeFile, mustExist: Boolean) = withContext(Dispatchers.IO) { + SystemFileSystem.delete(file.path, mustExist) + } +} 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 index 4fe327c1..37b13380 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt @@ -2,4 +2,14 @@ package io.github.chrisimx.scanbridge.model import kotlinx.io.files.Path -data class ScanBridgeFile(val path: Path) +data class ScanBridgeFile(val path: Path) { + constructor(pathString: String) : this(Path(pathString)) + + fun parent() = path.parent?.let { + ScanBridgeFile(it) + } + + fun child(fileName: String) = ScanBridgeFile( + Path(path, fileName) + ) +} 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..23d9441f --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt @@ -0,0 +1,11 @@ +package io.github.chrisimx.scanbridge.util + +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import kotlin.time.Clock + +fun ScanBridgeFile.getEditedImageName(): String { + val name = this.path.name + val baseName = name.substringBefore(" edit-") + + return "$baseName edit-${Clock.System.now().toEpochMilliseconds()}" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2884b03..e1d1c295 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ 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" @@ -103,6 +104,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose 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" } From a4986c5eec6e2d01a3c94609697fff831d54602b Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 11 Aug 2026 00:43:28 +0200 Subject: [PATCH 05/16] Remove fixed TODO --- .../github/chrisimx/scanbridge/scan/AndroidStartScanUseCase.kt | 1 - 1 file changed, 1 deletion(-) 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..31798084 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 @@ -40,7 +40,6 @@ class AndroidStartScanUseCase(val scanJobRepo: ScanJobRepository, val applicatio * @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() From 1da600bb66e67c2f27a0ac30c49c73707e15ed9e Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 11 Aug 2026 00:45:31 +0200 Subject: [PATCH 06/16] Move ScanJobRepository to core --- .../scanbridge/ScanBridgeApplication.kt | 2 +- .../chrisimx/scanbridge/ScanningScreen.kt | 2 +- .../ScanJobForegroundService.kt | 2 +- .../data/ui/ScanningScreenViewModel.kt | 2 +- .../scan/AndroidStartScanUseCase.kt | 2 +- .../repositories}/ScanJobRepository.kt | 24 +++++++++++-------- 6 files changed, 19 insertions(+), 15 deletions(-) rename {androidApp/src/main/java/io/github/chrisimx/scanbridge/services => core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/repositories}/ScanJobRepository.kt (82%) 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 ef259996..9fbaf02e 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -55,7 +55,7 @@ import io.github.chrisimx.scanbridge.scannerdiscovery.ScannerDiscoveryScreenView 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.repositories.ScanJobRepository import io.github.chrisimx.scanbridge.startupmessages.RoomShownStartupMessagesRepository import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository import io.github.chrisimx.scanbridge.usecases.StartScanUseCase diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 40e49872..92e63864 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -94,7 +94,7 @@ 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.repositories.ScanJobEvent import io.github.chrisimx.scanbridge.uicomponents.ExportSettingsPopup import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.LoadingScreen 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..c79f3eea 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 @@ -22,7 +22,7 @@ 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.repositories.ScanJobRepository import io.github.chrisimx.scanbridge.util.extractPdfImages import java.io.File import kotlin.jvm.java 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 3277dbe7..46f332bc 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 @@ -62,7 +62,7 @@ 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.repositories.ScanJobRepository import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.rotateBy90 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 31798084..89a9e0e3 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 @@ -10,7 +10,7 @@ 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.repositories.ScanJobRepository import io.github.chrisimx.scanbridge.usecases.StartScanUseCase import kotlin.uuid.Uuid diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/repositories/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/repositories/ScanJobRepository.kt index 2abc492e..54ea30d0 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/ScanJobRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/repositories/ScanJobRepository.kt @@ -1,9 +1,11 @@ -package io.github.chrisimx.scanbridge.services +package io.github.chrisimx.scanbridge.repositories import io.github.chrisimx.scanbridge.model.ScanJob import io.github.chrisimx.scanbridge.model.ScanningError +import io.github.chrisimx.scanbridge.ports.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 */ From 65e5ddb5e94a3f8c5642cc3edce40123a1ce3091 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 11 Aug 2026 00:46:25 +0200 Subject: [PATCH 07/16] Move LocaleProvider to localization package in core --- .../java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt | 2 +- .../chrisimx/scanbridge/services/AndroidLocaleProvider.kt | 2 +- .../chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt | 2 +- .../scanbridge/{ports => localization}/LocaleProvider.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ports => localization}/LocaleProvider.kt (83%) 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 9fbaf02e..0420fc60 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -38,7 +38,7 @@ 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.localization.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt index 7b262da6..a14ba25c 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt @@ -2,7 +2,7 @@ package io.github.chrisimx.scanbridge.services import android.annotation.SuppressLint import io.github.chrisimx.scanbridge.model.Locale -import io.github.chrisimx.scanbridge.ports.LocaleProvider +import io.github.chrisimx.scanbridge.localization.LocaleProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow 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..b2d08297 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt @@ -41,7 +41,7 @@ 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.localization.LocaleProvider import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.util.derived import kotlinx.coroutines.CoroutineScope 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 From 5e35cdf3a7ee350c64c8f2c0a999b9fca260c011 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 11 Aug 2026 11:03:30 +0200 Subject: [PATCH 08/16] Remove DebugLogService and related implementations --- .../scanbridge/ScanBridgeApplication.kt | 3 - .../chrisimx/scanbridge/logs/FileLogger.kt | 16 --- .../scanbridge/services/DebugLogService.kt | 10 -- .../services/FileDebugLogService.kt | 104 ------------------ 4 files changed, 133 deletions(-) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/logs/FileLogger.kt delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/services/DebugLogService.kt delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/services/FileDebugLogService.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 0420fc60..4b3bb5db 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -53,8 +53,6 @@ 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.repositories.ScanJobRepository import io.github.chrisimx.scanbridge.startupmessages.RoomShownStartupMessagesRepository import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository @@ -101,7 +99,6 @@ 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() 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/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) - } - } - } -} From 3d909e9e94f9784bd297f2c82a46b387a0ee8b91 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 12 Aug 2026 01:50:24 +0200 Subject: [PATCH 09/16] Port ScanningScreen to Compose multiplatform --- androidApp/gradle/libs.versions.toml | 100 --- .../scanbridge/ScanBridgeApplication.kt | 28 +- .../ScanJobForegroundService.kt | 1 + .../scanbridge/data/ui/ScanningScreenData.kt | 98 --- .../data/ui/ScanningScreenViewModel.kt | 721 ------------------ .../scanbridge/scan/AndroidScanExecutor.kt | 13 + .../scan/AndroidStartScanUseCase.kt | 50 -- .../uicomponents/ExportSettingsPopup.kt | 64 -- .../chrisimx/scanbridge/util/ImageUtil.kt | 5 - .../scanbridge/LicenseErrorStringMapping.kt | 48 +- .../chrisimx/scanbridge/SignupScreen.kt | 116 ++- .../zammadapi/models/VIPCreationResult.kt | 87 ++- composeUI/build.gradle.kts | 3 +- .../composeResources/values-de/strings.xml | 5 +- .../composeResources/values-it/strings.xml | 5 +- .../composeResources/values/strings.xml | 33 +- .../chrisimx/scanbridge/ScanningScreen.kt | 689 ++++++++--------- .../uicomponents/ExportSettingsPopup.kt | 88 +++ .../uicomponents/dialog/ConfirmCloseDialog.kt | 19 +- .../chrisimx/scanbridge/util/SnackbarUtils.kt | 50 +- core/build.gradle.kts | 7 + ...roidMdnsDiscoverServiceInstrumentedTest.kt | 1 - .../scanbridge}/AndroidHttpClientFactory.kt | 2 + .../scanbridge}/AndroidMdnsDiscoverService.kt | 2 + .../AndroidMulticastLockHandler.kt | 2 + .../AndroidScanBridgeDbBuilderFactory.kt | 2 + .../scanbridge}/InetAddressKMPConverter.kt | 2 + .../scanbridge}/TimingEventListener.kt | 2 + .../scanbridge}/TrustAllTrustManager.kt | 2 + .../cropfeature/AndroidImageCropService.kt | 7 +- .../AndroidImageRotationService.kt | 44 ++ .../scanbridge/ScanProtocolRegistry.kt | 2 +- .../scanbridge/ScanningScreenViewModel.kt | 527 +++++++++++++ .../cropfeature/FinishCropUseCase.kt | 21 +- .../db/migrations/KoinRoomMigrationsModule.kt | 2 +- .../scanbridge/export/ExportModule.kt | 10 + .../scanbridge/export/ExportModuleManager.kt | 8 + .../scanbridge/export/ExportModuleType.kt | 6 + .../export/KoinExportModuleManager.kt | 23 + .../scanbridge/export/KoinModuleExport.kt | 9 + .../scanbridge/export/NoopExportModule.kt | 14 + .../imagerotation/ImageRotationService.kt | 10 + .../imagerotation/RotateScanUseCase.kt | 43 ++ .../scanbridge/koin/KoinModuleCommon.kt | 16 + .../scanbridge/model/PositionAndHeight.kt | 7 + .../scanbridge/model/ScanBridgeFile.kt | 6 + .../scanbridge/scanning/DeleteScanUseCase.kt | 19 + .../scanning/DeleteSessionUseCase.kt | 46 ++ .../scanning/ExportAllPagesUseCase.kt | 38 + .../scanning/KoinModuleScanningUseCases.kt | 14 + .../scanbridge/scanning/ScanExecutor.kt | 5 + .../scanbridge/scanning/StartScanUseCase.kt | 52 ++ .../scanbridge/scanning/SwapPagesUseCase.kt | 14 + .../scanbridge/usecases/StartScanUseCase.kt | 15 - .../scanbridge/util/ScanFileNameUtil.kt | 7 +- gradle/libs.versions.toml | 7 +- 56 files changed, 1660 insertions(+), 1557 deletions(-) delete mode 100644 androidApp/gradle/libs.versions.toml delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutor.kt delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/ScanningScreen.kt (59%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/uicomponents/dialog/ConfirmCloseDialog.kt (73%) rename androidApp/src/main/java/io/github/chrisimx/scanbridge/util/UIUtils.kt => composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SnackbarUtils.kt (75%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/AndroidHttpClientFactory.kt (97%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/AndroidMdnsDiscoverService.kt (99%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/AndroidMulticastLockHandler.kt (96%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/AndroidScanBridgeDbBuilderFactory.kt (93%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/InetAddressKMPConverter.kt (93%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/TimingEventListener.kt (97%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/TrustAllTrustManager.kt (96%) rename core/src/androidMain/kotlin/{ => io/github/chrisimx/scanbridge}/cropfeature/AndroidImageCropService.kt (90%) create mode 100644 core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModule.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleManager.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModuleType.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinExportModuleManager.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/ImageRotationService.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/PositionAndHeight.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteScanUseCase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteSessionUseCase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningUseCases.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutor.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/SwapPagesUseCase.kt delete mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/usecases/StartScanUseCase.kt 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/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 4b3bb5db..d29ed3e4 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -1,14 +1,10 @@ package io.github.chrisimx.scanbridge -import AndroidHttpClientFactory -import AndroidMdnsDiscoverService -import AndroidMulticastLockHandler -import AndroidScanBridgeDbBuilderFactory import android.app.Application import android.content.Context import coil3.ImageLoader import coil3.network.ktor3.KtorNetworkFetcherFactory -import cropfeature.AndroidImageCropService +import io.github.chrisimx.scanbridge.cropfeature.AndroidImageCropService import io.github.chrisimx.localization.JvmNumberFormatter import io.github.chrisimx.scanbridge.adapters.KoinBasedScanningProtocolManager import io.github.chrisimx.scanbridge.adapters.RoomBackedCustomScannerRepository @@ -19,17 +15,20 @@ import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider 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.data.ui.ScanningScreenViewModel import io.github.chrisimx.scanbridge.db.DefaultScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory -import io.github.chrisimx.scanbridge.db.migrations.ROOM_MIGRATIONS +import io.github.chrisimx.scanbridge.export.ExportModule +import io.github.chrisimx.scanbridge.export.PdfNoopExportModule import io.github.chrisimx.scanbridge.filesystem.FileSystem import io.github.chrisimx.scanbridge.filesystem.KotlinIOFileSystem +import io.github.chrisimx.scanbridge.imagerotation.AndroidImageRotationService +import io.github.chrisimx.scanbridge.imagerotation.ImageRotationService 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.koin.KOIN_MODULE_COMMON import io.github.chrisimx.scanbridge.localization.NumberFormatter import io.github.chrisimx.scanbridge.migrations.MigrationExecutor import io.github.chrisimx.scanbridge.migrations.RoomBackedMigrationExecutor @@ -49,14 +48,14 @@ import io.github.chrisimx.scanbridge.repositories.DatastoreShownMessagesReposito 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.repositories.ScanJobRepository +import io.github.chrisimx.scanbridge.scan.AndroidScanExecutor +import io.github.chrisimx.scanbridge.scanning.ScanExecutor import io.github.chrisimx.scanbridge.startupmessages.RoomShownStartupMessagesRepository 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 @@ -132,7 +131,8 @@ val appModule = module { single() viewModel() single() bind MulticastLockHandler::class - single() bind StartScanUseCase::class + single() bind ScanExecutor::class + single() bind ImageRotationService::class single() bind BuildInfoProvider::class @@ -149,7 +149,13 @@ val appModule = module { single() single() bind FileSystem::class - includes(SCAN_PROTOCOLS, ROOM_MIGRATIONS, DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) + single() bind ExportModule::class + + includes( + KOIN_MODULE_COMMON + ) + + includes(DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } class ScanBridgeApplication : Application() { 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 c79f3eea..779e6fa5 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 @@ -23,6 +23,7 @@ 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.repositories.ScanJobRepository +import io.github.chrisimx.scanbridge.scanning.ScanExecutor import io.github.chrisimx.scanbridge.util.extractPdfImages import java.io.File import kotlin.jvm.java 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 a78b4454..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ /dev/null @@ -1,98 +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 -import org.jetbrains.compose.resources.DrawableResource -import org.jetbrains.compose.resources.StringResource - -data class ErrorDescription(val pretext: StringResource?, val icon: DrawableResource?, 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 46f332bc..00000000 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ /dev/null @@ -1,721 +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.ScanBridgeFile -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.repositories.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.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.jetbrains.compose.resources.DrawableResource -import org.jetbrains.compose.resources.StringResource -import org.koin.core.annotation.InjectedParam -import org.koin.core.scope.Scope -import scanbridge.composeui.generated.resources.Res -import scanbridge.composeui.generated.resources.exporting -import scanbridge.composeui.generated.resources.rotating_page -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: StringResource?) { - _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: StringResource? = null, errorIcon: DrawableResource? = 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(Res.string.rotating_page) - try { - val pagePath = - scannedPage.filePath - val pageScanBridgeFile = ScanBridgeFile(kotlinx.io.files.Path(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 = pageScanBridgeFile.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(Res.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(Res.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. Use outputName instead - "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/scan/AndroidScanExecutor.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutor.kt new file mode 100644 index 00000000..6446a292 --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutor.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.ScanExecutor + +class AndroidScanExecutor( + val application: Application +) : ScanExecutor { + 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 89a9e0e3..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,52 +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.repositories.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 { - 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/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/ImageUtil.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/util/ImageUtil.kt index 0fa5b3ae..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,11 +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.saveAsJPEG(file: File) { file.outputStream().use { this.compress(Bitmap.CompressFormat.JPEG, 100, it) 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/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/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index 319490eb..42929108 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -46,12 +46,13 @@ kotlin { 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 { diff --git a/composeUI/src/commonMain/composeResources/values-de/strings.xml b/composeUI/src/commonMain/composeResources/values-de/strings.xml index 37cf8caf..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 @@ -181,4 +181,7 @@ 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 4d2233e0..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 @@ -179,4 +179,7 @@ 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 417b9524..59999a2b 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 @@ -76,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. @@ -95,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 @@ -111,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. @@ -140,19 +140,19 @@ 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 + 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. + 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. + 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 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. + 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 @@ -183,4 +183,7 @@ Email Detailed information regarding the setting iOS + Error while exporting: + Error while deleting a scan: + The selected export module could not be found. \ No newline at end of file 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 59% 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 92e63864..0d99589f 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,9 @@ 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,14 +77,14 @@ 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.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.clipboard.toClipEntry import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.toUIInputSourceType @@ -100,14 +94,18 @@ 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.localizationhelper.toReadableString -import java.io.File +import io.github.chrisimx.scanbridge.model.PositionAndHeight +import io.github.chrisimx.scanbridge.platformhelper.PlatformBackHandler +import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog +import io.github.chrisimx.scanbridge.util.snackBarError +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.openFileSaver +import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -115,9 +113,11 @@ 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.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.androidx.compose.koinViewModel +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 @@ -127,6 +127,9 @@ 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 @@ -135,27 +138,27 @@ 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 scanbridge.composeui.generated.resources.twotone_wifi_find_24 -import timber.log.Timber 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( @@ -173,13 +176,11 @@ fun ScanningScreenBottomBar( scanningViewModel.setShowSaveOptionsPopup(true) }, modifier = Modifier.onGloballyPositioned { - onSaveButtonPositionChanged( - Triple( - it.positionInWindow().x.toInt(), - it.positionInWindow().y.toInt(), - it.size.height - ) - ) + setSaveButtonPositionAndHeight(PositionAndHeight( + it.positionInWindow().x.toInt(), + it.positionInWindow().y.toInt(), + it.size.height + )) } ) { Icon( @@ -192,13 +193,11 @@ fun ScanningScreenBottomBar( scanningViewModel.setShowExportOptionsPopup(true) }, modifier = Modifier.onGloballyPositioned { - onExportPositionChange( - Triple( - it.positionInWindow().x.toInt(), - it.positionInWindow().y.toInt(), - it.size.height - ) - ) + setExportButtonPositionAndHeight(PositionAndHeight( + it.positionInWindow().x.toInt(), + it.positionInWindow().y.toInt(), + it.size.height + )) } ) { Icon( @@ -272,10 +271,7 @@ fun ScanningScreenBottomBar( // Show scan button when not scanning ExtendedFloatingActionButton( onClick = { - scanningViewModel.scan( - scope, - snackbarHostState - ) + scanningViewModel.scan() }, modifier = Modifier.testTag("scanbtn"), icon = { @@ -292,16 +288,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, @@ -323,20 +310,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() @@ -344,6 +323,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 = { @@ -351,13 +346,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) } } @@ -373,44 +378,36 @@ 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" else "" + error.errorText() - AnimatedVisibility( - isError, - enter = fadeIn(animationSpec = tween(1000)), - exit = fadeOut(animationSpec = tween(1000)) - ) { - val errorDescription = scanningViewModel.scanningScreenData.error - FullScreenError( - errorDescription?.icon ?: Res.drawable.twotone_wifi_find_24, - stringResource( - errorDescription?.pretext ?: Res.string.scannercapabilities_retrieve_error, - errorDescription?.text ?: "Error text not found" - ), - copyButton = true - ) - } + snackBarError( + fullErrorText, + scope, + snackbarHostState, + hasErrorText, + errorText, + clipboard + ) } + } - return + PlatformBackHandler { + scanningViewModel.leaveRequested() } - BackHandler { - scanningViewModel.setConfirmDialogShown(true) + if (confirmLeaveDialogShown) { + ConfirmCloseDialog( + onDismiss = { scanningViewModel.onLeaveDismissed() }, + onConfirmed = { + scanningViewModel.onLeaveConfirmed { + navController.clearAndNavigateTo(StartUpScreenRoute) + } + } + ) } Scaffold( @@ -464,29 +461,39 @@ fun ScanningScreen( }, topBar = { }, 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, @@ -497,172 +504,108 @@ 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) - ) + var exportOptionsWidth by remember { mutableIntStateOf(0) } + val exportAlpha by animateFloatAsState( + targetValue = if (showExportPopup) 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) - ) + var saveOptionsWidth by remember { mutableIntStateOf(0) } + 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 file = FileKit.openFileSaver( + suggestedName = exportEvent.exportedFile.nameWithoutExtension, + defaultExtension = exportEvent.exportedFile.extension, + ) + // TODO: Add Sharing code - val saveFileLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> - uri?.let { uri -> - currentSourceFileToSave?.let { sourceFile -> - saveFile(context, sourceFile, uri) - } + scanningViewModel.onExportFinished(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) { + ExportSettingsPopup( + exportPositionAndHeight, + exportOptionsWidth, + exportAlpha, + onDismiss = { scanningViewModel.setShowExportOptionsPopup(false) }, + updateWidth = { exportOptionsWidth = it }, + availableExportModuleTypes = availableExportModules, + onExportModuleSelected = { selectedModule -> + scanningViewModel.exportAllPages( + selectedModule, + FileSaveType.Share + ) + } + ) + } + + if (saveOptionsAlpha > 0) { + ExportSettingsPopup( + saveButtonPositionAndHeight, + saveOptionsWidth, + saveOptionsAlpha, + onDismiss = { scanningViewModel.setShowSaveOptionsPopup(false) }, + updateWidth = { exportOptionsWidth = it }, + 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( - Res.string.delete_current_page, - Res.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, @@ -675,8 +618,6 @@ fun ScanContent( pagerState: PagerState, scanJobRunning: Boolean ) { - val context = LocalContext.current - Column( modifier = Modifier .fillMaxSize() @@ -746,109 +687,151 @@ 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( - Res.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( - 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) - ) - ) - } - } - }) { - Icon( - painterResource(Res.drawable.outline_crop_24), - contentDescription = stringResource( - Res.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( - 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 - ) - ) - } + 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) + } +} + +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 + } +} + @Composable private fun DownloadingPageFullscreen(innerPadding: PaddingValues) { Column( 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..500cb55a --- /dev/null +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt @@ -0,0 +1,88 @@ +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.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?, + exportOptionsWidth: Int, + alpha: Float, + onDismiss: () -> Unit, + updateWidth: (Int) -> Unit, + availableExportModuleTypes: List, + onExportModuleSelected: (ExportModuleType) -> Unit, +) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset( + exportOptionsPopupPosition?.x?.minus(exportOptionsWidth / 4) ?: 0, + exportOptionsPopupPosition?.y?.minus(exportOptionsPopupPosition.height) ?: 0 + ), + onDismissRequest = { onDismiss() } + ) { + ElevatedCard( + modifier = Modifier + .onGloballyPositioned { coordinates -> + updateWidth(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/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/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/core/build.gradle.kts b/core/build.gradle.kts index fd76013b..5968ea98 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -43,6 +43,8 @@ kotlin { iosArm64() iosSimulatorArm64() + applyDefaultHierarchyTemplate() + sourceSets { all { languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") @@ -59,6 +61,7 @@ kotlin { api(libs.wsdkt) api(libs.anyscanCore) api(libs.nonThrowingKtor) + api(libs.filekit.core) // Room deps implementation(libs.androidx.room.runtime) @@ -118,3 +121,7 @@ dependencies { add("kspIosSimulatorArm64", libs.androidx.room.compiler) add("kspIosArm64", libs.androidx.room.compiler) } + +koinCompiler { + compileSafety = true +} diff --git a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt index dbd38967..7eb5f01e 100644 --- a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt +++ b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt @@ -1,6 +1,5 @@ package io.github.chrisimx.scanbridge -import AndroidMdnsDiscoverService import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo diff --git a/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt similarity index 97% rename from core/src/androidMain/kotlin/AndroidHttpClientFactory.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt index 15586c79..8c478856 100644 --- a/core/src/androidMain/kotlin/AndroidHttpClientFactory.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt @@ -1,3 +1,5 @@ +package io.github.chrisimx.scanbridge + import io.github.chrisimx.scanbridge.model.HttpClientConfig import io.github.chrisimx.scanbridge.ports.HttpClientFactory import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory diff --git a/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt similarity index 99% rename from core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt index 8809a07b..66d310bb 100644 --- a/core/src/androidMain/kotlin/AndroidMdnsDiscoverService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt @@ -1,3 +1,5 @@ +package io.github.chrisimx.scanbridge + import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo diff --git a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt similarity index 96% rename from core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt index f21ca143..9e1b3b34 100644 --- a/core/src/androidMain/kotlin/AndroidMulticastLockHandler.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt @@ -1,3 +1,5 @@ +package io.github.chrisimx.scanbridge + import android.app.Application import android.content.Context import android.net.wifi.WifiManager 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/InetAddressKMPConverter.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/InetAddressKMPConverter.kt similarity index 93% rename from core/src/androidMain/kotlin/InetAddressKMPConverter.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/InetAddressKMPConverter.kt index 350ffdfe..33f32164 100644 --- a/core/src/androidMain/kotlin/InetAddressKMPConverter.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/InetAddressKMPConverter.kt @@ -1,3 +1,5 @@ +package io.github.chrisimx.scanbridge + import io.github.chrisimx.scanbridge.model.IpAddress import java.net.InetAddress 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/TrustAllTrustManager.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TrustAllTrustManager.kt similarity index 96% rename from core/src/androidMain/kotlin/TrustAllTrustManager.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TrustAllTrustManager.kt index 8d4abffe..c46441f2 100644 --- a/core/src/androidMain/kotlin/TrustAllTrustManager.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TrustAllTrustManager.kt @@ -1,3 +1,5 @@ +package io.github.chrisimx.scanbridge + import android.annotation.SuppressLint import java.security.SecureRandom import java.security.cert.X509Certificate diff --git a/core/src/androidMain/kotlin/cropfeature/AndroidImageCropService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt similarity index 90% rename from core/src/androidMain/kotlin/cropfeature/AndroidImageCropService.kt rename to core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt index 0285e76d..d0d2f48c 100644 --- a/core/src/androidMain/kotlin/cropfeature/AndroidImageCropService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt @@ -1,4 +1,4 @@ -package cropfeature +package io.github.chrisimx.scanbridge.cropfeature import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -42,7 +42,10 @@ class AndroidImageCropService( sourceBitmap.recycle() val croppedFile = File(outputPath.path.toString()) - croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, croppedFile.outputStream()) + croppedFile.outputStream().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..8db71068 --- /dev/null +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt @@ -0,0 +1,44 @@ +package io.github.chrisimx.scanbridge.imagerotation + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AndroidImageRotationService( + loggerFactory: ScanBridgeLoggerFactory +) : ImageRotationService { + val logger = loggerFactory.withClass(this::class) + + override suspend fun rotate90ToRight( + sourcePath: ScanBridgeFile, + outputPath: ScanBridgeFile + ): Boolean = withContext(Dispatchers.IO) { + val pathString = sourcePath.path.toString() + val sourceBitmap = BitmapFactory.decodeFile(pathString) + 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 rotatedFile = File(outputPath.path.toString()) + rotatedFile.outputStream().use { + rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) + } + rotatedBitmap.recycle() + + return@withContext true + } + +} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt index 07b004a2..8bcb5607 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt @@ -7,7 +7,7 @@ import org.koin.dsl.bind import org.koin.dsl.module import org.koin.plugin.module.dsl.single -val SCAN_PROTOCOLS = module { +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/ScanningScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt new file mode 100644 index 00000000..5a6156c6 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt @@ -0,0 +1,527 @@ +/* + * 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.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.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.ExportModuleManager +import io.github.chrisimx.scanbridge.export.ExportModuleType +import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +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.ports.ScanBridgeLoggerFactory +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.repositories.ScanJobRepository +import io.github.chrisimx.scanbridge.scanning.DeleteScanUseCase +import io.github.chrisimx.scanbridge.scanning.DeleteSessionUseCase +import io.github.chrisimx.scanbridge.scanning.ExportAllPagesResult +import io.github.chrisimx.scanbridge.scanning.ExportAllPagesUseCase +import io.github.chrisimx.scanbridge.imagerotation.RotateScanUseCase +import io.github.chrisimx.scanbridge.scanning.StartScanUseCase +import io.github.chrisimx.scanbridge.scanning.SwapPagesUseCase +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.copyTo +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, + 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) + + + private val _errorStream = MutableSharedFlow() + val errorStream = _errorStream.asSharedFlow() + + private val _exportQueue = Channel( + capacity = Channel.BUFFERED + ) + + val exportQueue = _exportQueue.receiveAsFlow() + + private suspend fun addTempFile(file: ScanBridgeFile) { + val tmpFile = TempFile(Uuid.generateV4(), sessionID, file.path.toString()) + 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 onExportFinished(exportEvent: ExportEvent, platformFile: PlatformFile?) { + if (platformFile == null) { + return + } + + viewModelScope.launch { + copyExportToPlatformFile(exportEvent, platformFile) + } + } + + private fun copyExportToPlatformFile(exportEvent: ExportEvent, destination: PlatformFile) = viewModelScope.launch { + val source = PlatformFile(exportEvent.exportedFile.path) + 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) + } + + is ExportAllPagesResult.Success -> { + _exportQueue.send( + ExportEvent(result.exportedFile, saveType) + ) + } + + is ExportAllPagesResult.ExportFailed -> { + sendErrorToUI(ScanningScreenError.ExportError(result.error)) + } + } + } 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() + 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: ScanBridgeFile, val saveType: FileSaveType, val exportId: Uuid = Uuid.generateV4()) 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 index a5f45e48..b0369623 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt @@ -1,19 +1,21 @@ package io.github.chrisimx.scanbridge.cropfeature 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.FileSystem import io.github.chrisimx.scanbridge.model.Rect import io.github.chrisimx.scanbridge.model.ScanBridgeFile -import io.github.chrisimx.scanbridge.util.getEditedImageName +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.util.getEditedFile class FinishCropUseCase( private val imageCropService: ImageCropService, scanBridgeDb: ScanBridgeDb, - private val fileSystem: FileSystem + private val fileSystem: FileSystem, + loggerFactory: ScanBridgeLoggerFactory ) { - val scannedPageDao = scanBridgeDb.scannedPageDao() + private val scannedPageDao = scanBridgeDb.scannedPageDao() + private val logger = loggerFactory.withClass(this::class) suspend operator fun invoke( page: ScannedPage, @@ -22,11 +24,12 @@ class FinishCropUseCase( val scanFile = ScanBridgeFile(page.filePath) // Determine an output for the cropped image - val editFileName = scanFile.getEditedImageName() - val editFile = scanFile - .parent() - ?.child(editFileName) - ?: return false + val editFile = scanFile.getEditedFile() + + if (editFile == null) { + logger.error { "Could not determine output file name for cropped image" } + return false + } val successfulCrop = imageCropService.crop( sourcePath = scanFile, 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/export/ExportModule.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModule.kt new file mode 100644 index 00000000..544b003a --- /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.chrisimx.scanbridge.model.ScanBridgeFile + +interface ExportModule { + val type: ExportModuleType + + suspend fun export(pagesToExport: List): ScanBridgeFile +} 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..cb42a567 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt @@ -0,0 +1,9 @@ +package io.github.chrisimx.scanbridge.export + +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val KOIN_MODULE_EXPORT = module { + 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..3be01f8e --- /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.chrisimx.scanbridge.model.ScanBridgeFile + +open class NoopExportModule( + override val type: ExportModuleType +) : ExportModule { + override suspend fun export(pagesToExport: List): ScanBridgeFile { + throw NotImplementedError("No-op export module") + } +} + +class PdfNoopExportModule : NoopExportModule(ExportModuleType.PDF) 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..e8a77c41 --- /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.chrisimx.scanbridge.model.ScanBridgeFile + +interface ImageRotationService { + suspend fun rotate90ToRight( + sourcePath: ScanBridgeFile, + outputPath: ScanBridgeFile, + ): 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..e0b0f7d4 --- /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.filesystem.FileSystem +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.util.getEditedFile +import kotlin.uuid.Uuid + +class RotateScanUseCase( + scanBridgeDb: ScanBridgeDb, + loggerFactory: ScanBridgeLoggerFactory, + val fileSystem: FileSystem, + 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 = ScanBridgeFile(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.toString() + ) + ) + + fileSystem.delete(scanFile) + } +} 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..4f958ba9 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt @@ -0,0 +1,16 @@ +package io.github.chrisimx.scanbridge.koin + +import io.github.chrisimx.scanbridge.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.scanning.KOIN_MODULE_SCANNING_USE_CASES +import org.koin.dsl.module + +val KOIN_MODULE_COMMON = module { + includes( + KOIN_MODULE_SCANNING_USE_CASES, + KOIN_MODULE_EXPORT, + KOIN_MODULE_SCAN_PROTOCOLS, + KOIN_MODULE_ROOM_MIGRATIONS + ) +} 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/ScanBridgeFile.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt index 37b13380..67c60b95 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt @@ -12,4 +12,10 @@ data class ScanBridgeFile(val path: Path) { fun child(fileName: String) = ScanBridgeFile( Path(path, fileName) ) + + val name = path.name + + val nameWithoutExtension = name.substringBeforeLast('.', "") + + val extension = name.substringAfterLast('.', "") } 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..9a20ea22 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteScanUseCase.kt @@ -0,0 +1,19 @@ +package io.github.chrisimx.scanbridge.scanning + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.db.entities.ScannedPage +import io.github.chrisimx.scanbridge.filesystem.FileSystem +import io.github.chrisimx.scanbridge.model.ScanBridgeFile + +class DeleteScanUseCase( + val fileSystem: FileSystem, + scanBridgeDb: ScanBridgeDb +) { + private val scannedPageDao = scanBridgeDb.scannedPageDao() + suspend fun deleteScan(scan: ScannedPage) { + scannedPageDao.delete(scan) + + val scannedPageFile = ScanBridgeFile(scan.filePath) + fileSystem.delete(scannedPageFile) + } +} 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..3f1b72da --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteSessionUseCase.kt @@ -0,0 +1,46 @@ +package io.github.chrisimx.scanbridge.scanning + +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.filesystem.FileSystem +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import kotlin.uuid.Uuid +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext + +class DeleteSessionUseCase( + val fileSystem: FileSystem, + 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 { ScanBridgeFile(it.filePath) } + tmpPaths += tmpFiles.map { ScanBridgeFile(it.path) } + + sessionDao.deleteById(sessionId) + } + } + + withContext(Dispatchers.IO) { + filePaths.forEach { + fileSystem.delete(it, mustExist = false) + } + tmpPaths.forEach { + fileSystem.delete(it, 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..cc379ea2 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt @@ -0,0 +1,38 @@ +package io.github.chrisimx.scanbridge.scanning + +import io.github.chrisimx.scanbridge.db.ScanBridgeDb +import io.github.chrisimx.scanbridge.export.ExportModuleManager +import io.github.chrisimx.scanbridge.export.ExportModuleType +import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import kotlin.uuid.Uuid +import kotlinx.coroutines.CancellationException + + +class ExportAllPagesUseCase( + val exportModuleManager: ExportModuleManager, + scanBridgeDb: ScanBridgeDb +) { + val scannedPageDao = scanBridgeDb.scannedPageDao() + + suspend operator fun invoke(exportModuleType: ExportModuleType, sessionId: Uuid): ExportAllPagesResult { + val exportModule = exportModuleManager.getExportModuleByType(exportModuleType) + ?: return ExportAllPagesResult.ExportModuleNotFound + + val scannedPages = scannedPageDao.getAllForSession(sessionId) + + return try { + val exportFile = exportModule.export(scannedPages) + ExportAllPagesResult.Success(exportFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ExportAllPagesResult.ExportFailed(e) + } + } +} + +sealed class ExportAllPagesResult { + data class Success(val exportedFile: ScanBridgeFile) : ExportAllPagesResult() + data class ExportFailed(val error: Throwable) : ExportAllPagesResult() + object ExportModuleNotFound : ExportAllPagesResult() +} 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/ScanExecutor.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutor.kt new file mode 100644 index 00000000..8dbf0d22 --- /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 { + fun start() +} 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..2a7e6397 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt @@ -0,0 +1,52 @@ +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.ports.ScannerConnectionSettings +import io.github.chrisimx.scanbridge.repositories.ScanJobRepository +import kotlin.uuid.Uuid + +class StartScanUseCase( + val scanJobRepo: ScanJobRepository, + val scanExecutor: ScanExecutor +) { + fun startScan( + ownerSessionId: Uuid, + scannerHandle: ScannerHandle, + scanSettings: CommonScanSettings, + connectionSettings: ScannerConnectionSettings + ) { + val scanJob = ScanJob( + Uuid.generateV4(), + ownerSessionId, + scanSettings.applyFileFormat(), + scannerHandle, + connectionSettings + ) + + scanJobRepo.enqueue(scanJob) + scanExecutor.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/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/ScanFileNameUtil.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt index 23d9441f..d128c5d2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt @@ -3,9 +3,12 @@ package io.github.chrisimx.scanbridge.util import io.github.chrisimx.scanbridge.model.ScanBridgeFile import kotlin.time.Clock -fun ScanBridgeFile.getEditedImageName(): String { +fun ScanBridgeFile.getEditedFile(): ScanBridgeFile? { val name = this.path.name val baseName = name.substringBefore(" edit-") + val editName = "$baseName edit-${Clock.System.now().toEpochMilliseconds()}" - return "$baseName edit-${Clock.System.now().toEpochMilliseconds()}" + return this + .parent() + ?.child(editName) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1d1c295..7dcac0eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,8 +39,8 @@ 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" @@ -48,6 +48,7 @@ paraphrase-plugin = "0.4.1" ksp = "2.3.6" protoc = "4.33.5" androidx-sqlite = "2.6.2" +filekit = "0.15.0" [libraries] @@ -131,6 +132,8 @@ 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"} escl-mock-server = { module = "io.github.chrisimx:escl-mock-server", version.ref = "escl-mock-server"} From 08d9c2da2cfe0352621cb7be753ab28979388b55 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 13 Aug 2026 00:17:57 +0200 Subject: [PATCH 10/16] Replace ScanBridgeFile with PlatformFile --- .../scanbridge/ScanBridgeApplication.kt | 3 -- .../chrisimx/scanbridge/ScanningScreen.kt | 2 ++ .../cropfeature/AndroidImageCropService.kt | 29 +++++++++++++----- .../AndroidImageRotationService.kt | 30 ++++++++++++++----- .../scanbridge/ScanningScreenViewModel.kt | 8 ++--- .../cropfeature/FinishCropUseCase.kt | 16 +++++----- .../cropfeature/ImageCropService.kt | 6 ++-- .../scanbridge/export/ExportModule.kt | 4 +-- .../scanbridge/export/NoopExportModule.kt | 4 +-- .../scanbridge/filesystem/FileSystem.kt | 10 ------- .../filesystem/KotlinIOFileSystem.kt | 13 -------- .../imagerotation/ImageRotationService.kt | 6 ++-- .../imagerotation/RotateScanUseCase.kt | 12 ++++---- .../scanbridge/model/ScanBridgeFile.kt | 21 ------------- .../chrisimx/scanbridge/ports/ZipService.kt | 6 ++-- .../scanbridge/scanning/DeleteScanUseCase.kt | 9 +++--- .../scanning/DeleteSessionUseCase.kt | 17 +++++------ .../scanning/ExportAllPagesUseCase.kt | 4 +-- .../scanbridge/util/ScanFileNameUtil.kt | 15 ++++++---- 19 files changed, 100 insertions(+), 115 deletions(-) delete mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileSystem.kt delete mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt delete mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index d29ed3e4..3553f653 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -21,8 +21,6 @@ import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory import io.github.chrisimx.scanbridge.export.ExportModule import io.github.chrisimx.scanbridge.export.PdfNoopExportModule -import io.github.chrisimx.scanbridge.filesystem.FileSystem -import io.github.chrisimx.scanbridge.filesystem.KotlinIOFileSystem import io.github.chrisimx.scanbridge.imagerotation.AndroidImageRotationService import io.github.chrisimx.scanbridge.imagerotation.ImageRotationService import io.github.chrisimx.scanbridge.infrastructure.KmLogScanBridgeLoggerFactory @@ -147,7 +145,6 @@ val appModule = module { viewModel() single() bind ImageCropService::class single() - single() bind FileSystem::class single() bind ExportModule::class diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt index 0d99589f..2f734181 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -105,6 +105,8 @@ import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog 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 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 index d0d2f48c..c13bfc99 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt @@ -2,23 +2,32 @@ package io.github.chrisimx.scanbridge.cropfeature import android.graphics.Bitmap import android.graphics.BitmapFactory -import io.github.chrisimx.scanbridge.cropfeature.ImageCropService import io.github.chrisimx.scanbridge.model.Rect -import io.github.chrisimx.scanbridge.model.ScanBridgeFile import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory -import java.io.File +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(sourcePath: ScanBridgeFile, outputPath: ScanBridgeFile, cropRect: Rect): Boolean = withContext(Dispatchers.IO) { - val pathString = sourcePath.path.toString() - val sourceBitmap = BitmapFactory.decodeFile(pathString) + 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" @@ -41,8 +50,12 @@ class AndroidImageCropService( ) sourceBitmap.recycle() - val croppedFile = File(outputPath.path.toString()) - croppedFile.outputStream().use { + val outputFileOutputStream = outputFile + .sink(append = false) + .buffered() + .asOutputStream() + + outputFileOutputStream.use { croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } 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 index 8db71068..37ddc1e0 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt @@ -3,11 +3,15 @@ package io.github.chrisimx.scanbridge.imagerotation import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix -import io.github.chrisimx.scanbridge.model.ScanBridgeFile import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory -import java.io.File +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 @@ -15,11 +19,17 @@ class AndroidImageRotationService( val logger = loggerFactory.withClass(this::class) override suspend fun rotate90ToRight( - sourcePath: ScanBridgeFile, - outputPath: ScanBridgeFile + sourceFile: PlatformFile, + outputFile: PlatformFile ): Boolean = withContext(Dispatchers.IO) { - val pathString = sourcePath.path.toString() - val sourceBitmap = BitmapFactory.decodeFile(pathString) + 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" @@ -32,8 +42,12 @@ class AndroidImageRotationService( sourceBitmap.recycle() - val rotatedFile = File(outputPath.path.toString()) - rotatedFile.outputStream().use { + val rotatedFileOutputStream = outputFile + .sink(append = false) + .buffered() + .asOutputStream() + + rotatedFileOutputStream.use { rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } rotatedBitmap.recycle() diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt index 5a6156c6..44994361 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt @@ -38,7 +38,6 @@ 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.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider -import io.github.chrisimx.scanbridge.model.ScanBridgeFile import io.github.chrisimx.scanbridge.model.ScanSettingsEnterableDataV1 import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.scannerCapabilities @@ -56,6 +55,7 @@ import io.github.chrisimx.scanbridge.scanning.StartScanUseCase import io.github.chrisimx.scanbridge.scanning.SwapPagesUseCase 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 @@ -185,8 +185,8 @@ class ScanningScreenViewModel( val exportQueue = _exportQueue.receiveAsFlow() - private suspend fun addTempFile(file: ScanBridgeFile) { - val tmpFile = TempFile(Uuid.generateV4(), sessionID, file.path.toString()) + private suspend fun addTempFile(file: PlatformFile) { + val tmpFile = TempFile(Uuid.generateV4(), sessionID, file.path) tmpFileDao.insertAll(tmpFile) } @@ -524,4 +524,4 @@ enum class FileSaveType { Save } -data class ExportEvent(val exportedFile: ScanBridgeFile, val saveType: FileSaveType, val exportId: Uuid = Uuid.generateV4()) +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/cropfeature/FinishCropUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt index b0369623..bbb27b4a 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt @@ -2,16 +2,16 @@ 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.filesystem.FileSystem import io.github.chrisimx.scanbridge.model.Rect -import io.github.chrisimx.scanbridge.model.ScanBridgeFile import io.github.chrisimx.scanbridge.ports.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, - private val fileSystem: FileSystem, loggerFactory: ScanBridgeLoggerFactory ) { private val scannedPageDao = scanBridgeDb.scannedPageDao() @@ -21,7 +21,7 @@ class FinishCropUseCase( page: ScannedPage, cropRect: Rect, ): Boolean { - val scanFile = ScanBridgeFile(page.filePath) + val scanFile = PlatformFile(page.filePath) // Determine an output for the cropped image val editFile = scanFile.getEditedFile() @@ -32,8 +32,8 @@ class FinishCropUseCase( } val successfulCrop = imageCropService.crop( - sourcePath = scanFile, - outputPath = editFile, + sourceFile = scanFile, + outputFile = editFile, cropRect = cropRect, ) @@ -43,12 +43,12 @@ class FinishCropUseCase( scannedPageDao.update( page.copy( - filePath = editFile.path.toString() + filePath = editFile.path ) ) // Delete the original file - fileSystem.delete(scanFile) + 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 index 4460918a..dd6ef1ee 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/ImageCropService.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/ImageCropService.kt @@ -1,12 +1,12 @@ package io.github.chrisimx.scanbridge.cropfeature import io.github.chrisimx.scanbridge.model.Rect -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.vinceglb.filekit.PlatformFile interface ImageCropService { suspend fun crop( - sourcePath: ScanBridgeFile, - outputPath: ScanBridgeFile, + sourceFile: PlatformFile, + outputFile: PlatformFile, cropRect: Rect, ): Boolean } 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 index 544b003a..0fa7263d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModule.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportModule.kt @@ -1,10 +1,10 @@ package io.github.chrisimx.scanbridge.export import io.github.chrisimx.scanbridge.db.entities.ScannedPage -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.vinceglb.filekit.PlatformFile interface ExportModule { val type: ExportModuleType - suspend fun export(pagesToExport: List): ScanBridgeFile + suspend fun export(pagesToExport: List): PlatformFile } 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 index 3be01f8e..2e3c7503 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt @@ -1,12 +1,12 @@ package io.github.chrisimx.scanbridge.export import io.github.chrisimx.scanbridge.db.entities.ScannedPage -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.vinceglb.filekit.PlatformFile open class NoopExportModule( override val type: ExportModuleType ) : ExportModule { - override suspend fun export(pagesToExport: List): ScanBridgeFile { + override suspend fun export(pagesToExport: List): PlatformFile { throw NotImplementedError("No-op export module") } } diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileSystem.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileSystem.kt deleted file mode 100644 index 3034a302..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileSystem.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.chrisimx.scanbridge.filesystem - -import io.github.chrisimx.scanbridge.model.ScanBridgeFile - -interface FileSystem { - suspend fun delete( - file: ScanBridgeFile, - mustExist: Boolean = true - ) -} diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt deleted file mode 100644 index f5e65468..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KotlinIOFileSystem.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.chrisimx.scanbridge.filesystem - -import io.github.chrisimx.scanbridge.model.ScanBridgeFile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.withContext -import kotlinx.io.files.SystemFileSystem - -class KotlinIOFileSystem : FileSystem { - override suspend fun delete(file: ScanBridgeFile, mustExist: Boolean) = withContext(Dispatchers.IO) { - SystemFileSystem.delete(file.path, mustExist) - } -} 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 index e8a77c41..cee7fc0c 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/ImageRotationService.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/ImageRotationService.kt @@ -1,10 +1,10 @@ package io.github.chrisimx.scanbridge.imagerotation -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.vinceglb.filekit.PlatformFile interface ImageRotationService { suspend fun rotate90ToRight( - sourcePath: ScanBridgeFile, - outputPath: ScanBridgeFile, + 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 index e0b0f7d4..2211388d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt @@ -1,16 +1,16 @@ package io.github.chrisimx.scanbridge.imagerotation import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.filesystem.FileSystem -import io.github.chrisimx.scanbridge.model.ScanBridgeFile import io.github.chrisimx.scanbridge.ports.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 fileSystem: FileSystem, val imageRotationService: ImageRotationService ) { private val scannedPageDao = scanBridgeDb.scannedPageDao() @@ -18,7 +18,7 @@ class RotateScanUseCase( suspend fun rotateScan(scannedPageId: Uuid) { val scannedPage = scannedPageDao.getByScanId(scannedPageId) ?: return - val scanFile = ScanBridgeFile(scannedPage.filePath) + val scanFile = PlatformFile(scannedPage.filePath) val editFile = scanFile.getEditedFile() if (editFile == null) { @@ -34,10 +34,10 @@ class RotateScanUseCase( scannedPageDao.update( scannedPage.copy( - filePath = editFile.path.toString() + filePath = editFile.path ) ) - fileSystem.delete(scanFile) + scanFile.delete() } } 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 67c60b95..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/ScanBridgeFile.kt +++ /dev/null @@ -1,21 +0,0 @@ -package io.github.chrisimx.scanbridge.model - -import kotlinx.io.files.Path - -data class ScanBridgeFile(val path: Path) { - constructor(pathString: String) : this(Path(pathString)) - - fun parent() = path.parent?.let { - ScanBridgeFile(it) - } - - fun child(fileName: String) = ScanBridgeFile( - Path(path, fileName) - ) - - val name = path.name - - val nameWithoutExtension = name.substringBeforeLast('.', "") - - val extension = name.substringAfterLast('.', "") -} 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 index e58c298e..1f0a1bcc 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt @@ -1,7 +1,9 @@ package io.github.chrisimx.scanbridge.ports -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.name + interface ZipService { - fun zip(files: List, output: ScanBridgeFile, mapFileName: (ScanBridgeFile) -> String = { it.path.name }) + fun zip(files: List, output: PlatformFile, mapFileName: (PlatformFile) -> String = { it.name }) } 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 index 9a20ea22..085de712 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteScanUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteScanUseCase.kt @@ -2,18 +2,17 @@ package io.github.chrisimx.scanbridge.scanning import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.entities.ScannedPage -import io.github.chrisimx.scanbridge.filesystem.FileSystem -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.delete class DeleteScanUseCase( - val fileSystem: FileSystem, scanBridgeDb: ScanBridgeDb ) { private val scannedPageDao = scanBridgeDb.scannedPageDao() suspend fun deleteScan(scan: ScannedPage) { scannedPageDao.delete(scan) - val scannedPageFile = ScanBridgeFile(scan.filePath) - fileSystem.delete(scannedPageFile) + 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 index 3f1b72da..c1d5efb2 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteSessionUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/DeleteSessionUseCase.kt @@ -3,15 +3,14 @@ package io.github.chrisimx.scanbridge.scanning import androidx.room.immediateTransaction import androidx.room.useWriterConnection import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.filesystem.FileSystem -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +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 fileSystem: FileSystem, val db: ScanBridgeDb ) { val sessionDao = db.sessionDao() @@ -19,16 +18,16 @@ class DeleteSessionUseCase( val tmpFileDao = db.tmpFileDao() suspend fun deleteSession(sessionId: Uuid) { - val filePaths = mutableListOf() - val tmpPaths = mutableListOf() + val filePaths = mutableListOf() + val tmpPaths = mutableListOf() db.useWriterConnection { it.immediateTransaction { val scannedPages = scannedPageDao.getAllForSession(sessionId) val tmpFiles = tmpFileDao.getFilesBySessionId(sessionId) - filePaths += scannedPages.map { ScanBridgeFile(it.filePath) } - tmpPaths += tmpFiles.map { ScanBridgeFile(it.path) } + filePaths += scannedPages.map { PlatformFile(it.filePath) } + tmpPaths += tmpFiles.map { PlatformFile(it.path) } sessionDao.deleteById(sessionId) } @@ -36,10 +35,10 @@ class DeleteSessionUseCase( withContext(Dispatchers.IO) { filePaths.forEach { - fileSystem.delete(it, mustExist = false) + it.delete(mustExist = false) } tmpPaths.forEach { - fileSystem.delete(it, mustExist = false) + 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 index cc379ea2..33a5d897 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt @@ -3,7 +3,7 @@ package io.github.chrisimx.scanbridge.scanning import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.export.ExportModuleManager import io.github.chrisimx.scanbridge.export.ExportModuleType -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +import io.github.vinceglb.filekit.PlatformFile import kotlin.uuid.Uuid import kotlinx.coroutines.CancellationException @@ -32,7 +32,7 @@ class ExportAllPagesUseCase( } sealed class ExportAllPagesResult { - data class Success(val exportedFile: ScanBridgeFile) : ExportAllPagesResult() + data class Success(val exportedFile: PlatformFile) : ExportAllPagesResult() data class ExportFailed(val error: Throwable) : ExportAllPagesResult() object ExportModuleNotFound : ExportAllPagesResult() } 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 index d128c5d2..af32c6cd 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ScanFileNameUtil.kt @@ -1,14 +1,17 @@ package io.github.chrisimx.scanbridge.util -import io.github.chrisimx.scanbridge.model.ScanBridgeFile +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 ScanBridgeFile.getEditedFile(): ScanBridgeFile? { - val name = this.path.name +fun PlatformFile.getEditedFile(): PlatformFile? { + val name = this.name val baseName = name.substringBefore(" edit-") val editName = "$baseName edit-${Clock.System.now().toEpochMilliseconds()}" - return this - .parent() - ?.child(editName) + return this.parent()?.let { + PlatformFile(it, editName) + } } From a3f02f3bc337c73cbc3c8f25bad2c7176bcaa611 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Sun, 16 Aug 2026 16:16:17 +0200 Subject: [PATCH 11/16] Create ZIP export module and implement cross-platform file sharing --- .../AndroidExportCapabilitiesProvider.kt | 7 ++ .../chrisimx/scanbridge/MainActivity.kt | 4 + .../scanbridge/ScanBridgeApplication.kt | 5 +- composeUI/build.gradle.kts | 9 ++ .../chrisimx/scanbridge/ScanningScreen.kt | 94 +++++++++++-------- .../chrisimx/scanbridge/util/SharingHelper.kt | 5 + .../scanbridge/util/SharingHelper.jvm.kt | 7 ++ .../scanbridge/util/SharingHelper.mobile.kt | 9 ++ core/build.gradle.kts | 4 +- .../scanbridge/AndroidMdnsDiscoverService.kt | 6 +- .../scanbridge/ScanningScreenViewModel.kt | 22 +++-- .../export/DefaultExportDirectoryProvider.kt | 12 +++ .../export/DefaultExportFileManager.kt | 27 ++++++ .../export/ExportCapabilitiesProvider.kt | 7 ++ .../export/ExportDirectoryProvider.kt | 7 ++ .../scanbridge/export/ExportFileManager.kt | 7 ++ .../scanbridge/export/KoinModuleExport.kt | 15 +++ .../scanbridge/export/NoopExportModule.kt | 2 - .../export/zip/KmpNativeZipBasedZipService.kt | 83 ++++++++++++++++ .../scanbridge/export/zip/ZipExportModule.kt | 32 +++++++ .../scanbridge/export/zip/ZipService.kt | 10 ++ .../FileKitStandardDirectoryProvider.kt | 16 ++++ .../filesystem/KoinModuleFilesystem.kt | 9 ++ .../filesystem/StandardDirectoryProvider.kt | 9 ++ .../scanbridge/koin/KoinModuleCommon.kt | 4 +- .../scanbridge/model/FileToBeExported.kt | 8 ++ .../chrisimx/scanbridge/ports/ZipService.kt | 9 -- .../scanning/ExportAllPagesUseCase.kt | 15 ++- gradle/libs.versions.toml | 5 + 29 files changed, 384 insertions(+), 65 deletions(-) create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.kt create mode 100644 composeUI/src/jvmMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.jvm.kt create mode 100644 composeUI/src/mobileMain/kotlin/io/github/chrisimx/scanbridge/util/SharingHelper.mobile.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportDirectoryProvider.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/DefaultExportFileManager.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportCapabilitiesProvider.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportDirectoryProvider.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportFileManager.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/KmpNativeZipBasedZipService.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipExportModule.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/zip/ZipService.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/FileKitStandardDirectoryProvider.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/KoinModuleFilesystem.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/filesystem/StandardDirectoryProvider.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/model/FileToBeExported.kt delete mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt 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..e3540dad --- /dev/null +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt @@ -0,0 +1,7 @@ +package io.github.chrisimx.scanbridge + +import io.github.chrisimx.scanbridge.export.ExportCapabilitiesProvider + +class AndroidExportCapabilitiesProvider : ExportCapabilitiesProvider { + override val supportedFileSaveTypes: Set = setOf(FileSaveType.Save, FileSaveType.Share) +} 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..dfeb1e7b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt @@ -27,6 +27,8 @@ 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.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.init import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -58,6 +60,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/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 3553f653..088a621d 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -19,8 +19,7 @@ import io.github.chrisimx.scanbridge.db.DefaultScanBridgeDbFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory -import io.github.chrisimx.scanbridge.export.ExportModule -import io.github.chrisimx.scanbridge.export.PdfNoopExportModule +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.infrastructure.KmLogScanBridgeLoggerFactory @@ -146,7 +145,7 @@ val appModule = module { single() bind ImageCropService::class single() - single() bind ExportModule::class + single() bind ExportCapabilitiesProvider::class includes( KOIN_MODULE_COMMON diff --git a/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index 42929108..8afebee8 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -31,6 +31,8 @@ kotlin { iosArm64() iosSimulatorArm64() + applyDefaultHierarchyTemplate() + sourceSets { commonMain.dependencies { api(libs.compose.runtime) @@ -66,6 +68,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/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt index 2f734181..b11454d8 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -84,7 +84,6 @@ 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.clipboard.toClipEntry import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.model.ScannerHandle import io.github.chrisimx.scanbridge.model.toUIInputSourceType @@ -102,6 +101,7 @@ 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.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 @@ -173,39 +173,45 @@ fun ScanningScreenBottomBar( ) { Icon(Icons.Filled.Settings, contentDescription = stringResource(Res.string.settings)) } - IconButton( - onClick = { - scanningViewModel.setShowSaveOptionsPopup(true) - }, - modifier = Modifier.onGloballyPositioned { - setSaveButtonPositionAndHeight(PositionAndHeight( - 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(Res.drawable.outline_file_save_24), - contentDescription = stringResource(Res.string.save_to_file) - ) } - IconButton( - onClick = { - scanningViewModel.setShowExportOptionsPopup(true) - }, - modifier = Modifier.onGloballyPositioned { - setExportButtonPositionAndHeight(PositionAndHeight( - it.positionInWindow().x.toInt(), - it.positionInWindow().y.toInt(), - it.size.height - )) + 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(Res.string.export) - ) } }, floatingActionButton = { @@ -384,7 +390,7 @@ fun ScanningScreen( scanningViewModel.errorStream.collect { error -> val errorText = error.errorText() val hasErrorText = errorText != null - val fullErrorText = error.errorPretext() + if (hasErrorText) "\n\n" else "" + error.errorText() + val fullErrorText = error.errorPretext() + if (hasErrorText) "\n\n" + error.errorText() else "" snackBarError( fullErrorText, @@ -534,13 +540,19 @@ fun ScanningScreen( LaunchedEffect(Unit) { scanningViewModel.exportQueue.collect { exportEvent -> - val file = FileKit.openFileSaver( - suggestedName = exportEvent.exportedFile.nameWithoutExtension, - defaultExtension = exportEvent.exportedFile.extension, - ) - // TODO: Add Sharing code - scanningViewModel.onExportFinished(exportEvent, file) + 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) + } + } } } @@ -548,7 +560,7 @@ fun ScanningScreen( modifier = Modifier .fillMaxSize() ) { - if (exportAlpha > 0) { + if (exportAlpha > 0 && scanningViewModel.shareSupported) { ExportSettingsPopup( exportPositionAndHeight, exportOptionsWidth, @@ -565,7 +577,7 @@ fun ScanningScreen( ) } - if (saveOptionsAlpha > 0) { + if (saveOptionsAlpha > 0 && scanningViewModel.saveSupported) { ExportSettingsPopup( saveButtonPositionAndHeight, saveOptionsWidth, @@ -823,6 +835,7 @@ private suspend fun ScanningScreenError.errorPretext(): String { 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) } } @@ -831,6 +844,7 @@ private fun ScanningScreenError.errorText(): String? { is ScanningScreenError.DeletionError -> this.error.toString() is ScanningScreenError.ExportError -> this.error.toString() ScanningScreenError.ExportModuleNotFound, ScanningScreenError.JobStillRunning -> null + ScanningScreenError.NoPagesScannedYet -> null } } 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/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 5968ea98..de8b387b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -67,7 +67,9 @@ kotlin { 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 { diff --git a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt index 66d310bb..f8bcfe8c 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt @@ -199,7 +199,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/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt index 44994361..4f80c933 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt @@ -35,6 +35,7 @@ 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 @@ -103,6 +104,7 @@ class ScanningScreenViewModel( val koinScope: Scope, val loggerFactory: ScanBridgeLoggerFactory, val exportModuleManager: ExportModuleManager, + exportCapabilitiesProvider: ExportCapabilitiesProvider, val deleteScanUseCase: DeleteScanUseCase, val deleteSessionUseCase: DeleteSessionUseCase, val rotatePageUseCase: RotateScanUseCase, @@ -175,6 +177,8 @@ class ScanningScreenViewModel( 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() @@ -255,7 +259,7 @@ class ScanningScreenViewModel( } } - fun onExportFinished(exportEvent: ExportEvent, platformFile: PlatformFile?) { + fun onSaveLocationSelected(exportEvent: ExportEvent, platformFile: PlatformFile?) { if (platformFile == null) { return } @@ -266,7 +270,7 @@ class ScanningScreenViewModel( } private fun copyExportToPlatformFile(exportEvent: ExportEvent, destination: PlatformFile) = viewModelScope.launch { - val source = PlatformFile(exportEvent.exportedFile.path) + val source = exportEvent.exportedFile withContext(Dispatchers.IO) { source.copyTo(destination) } @@ -442,15 +446,19 @@ class ScanningScreenViewModel( sendErrorToUI(ScanningScreenError.ExportModuleNotFound) } - is ExportAllPagesResult.Success -> { - _exportQueue.send( - ExportEvent(result.exportedFile, saveType) - ) + 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)) @@ -508,7 +516,9 @@ class ScanningScreenViewModel( sealed class ScanningScreenError { object JobStillRunning : ScanningScreenError() object ExportModuleNotFound : ScanningScreenError() + object NoPagesScannedYet : ScanningScreenError() class ExportError(val error: Throwable) : ScanningScreenError() + class DeletionError(val error: Throwable) : ScanningScreenError() } 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..64ab02be --- /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.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/KoinModuleExport.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt index cb42a567..b35ec2ac 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt @@ -1,9 +1,24 @@ 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 { + + + + // 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 index 2e3c7503..db13199e 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt @@ -10,5 +10,3 @@ open class NoopExportModule( 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/koin/KoinModuleCommon.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt index 4f958ba9..6f3e5ade 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt @@ -3,6 +3,7 @@ package io.github.chrisimx.scanbridge.koin import io.github.chrisimx.scanbridge.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.scanning.KOIN_MODULE_SCANNING_USE_CASES import org.koin.dsl.module @@ -11,6 +12,7 @@ val KOIN_MODULE_COMMON = module { KOIN_MODULE_SCANNING_USE_CASES, KOIN_MODULE_EXPORT, KOIN_MODULE_SCAN_PROTOCOLS, - KOIN_MODULE_ROOM_MIGRATIONS + KOIN_MODULE_ROOM_MIGRATIONS, + KOIN_MODULE_FILESYSTEM ) } 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/ports/ZipService.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt deleted file mode 100644 index 1f0a1bcc..00000000 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ports/ZipService.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.chrisimx.scanbridge.ports - -import io.github.vinceglb.filekit.PlatformFile -import io.github.vinceglb.filekit.name - - -interface ZipService { - fun zip(files: List, output: PlatformFile, mapFileName: (PlatformFile) -> String = { it.name }) -} 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 index 33a5d897..514679be 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ExportAllPagesUseCase.kt @@ -1,9 +1,11 @@ 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 @@ -13,6 +15,7 @@ class ExportAllPagesUseCase( scanBridgeDb: ScanBridgeDb ) { val scannedPageDao = scanBridgeDb.scannedPageDao() + val tmpFileDao = scanBridgeDb.tmpFileDao() suspend operator fun invoke(exportModuleType: ExportModuleType, sessionId: Uuid): ExportAllPagesResult { val exportModule = exportModuleManager.getExportModuleByType(exportModuleType) @@ -20,9 +23,16 @@ class ExportAllPagesUseCase( val scannedPages = scannedPageDao.getAllForSession(sessionId) + if (scannedPages.isEmpty()) { + return ExportAllPagesResult.NoPages + } + return try { val exportFile = exportModule.export(scannedPages) - ExportAllPagesResult.Success(exportFile) + tmpFileDao.insertAll( + TempFile(ownerSessionId = sessionId, path = exportFile.path) + ) + ExportAllPagesResult.Success(exportFile) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -34,5 +44,6 @@ class ExportAllPagesUseCase( sealed class ExportAllPagesResult { data class Success(val exportedFile: PlatformFile) : ExportAllPagesResult() data class ExportFailed(val error: Throwable) : ExportAllPagesResult() - object ExportModuleNotFound : ExportAllPagesResult() + data object ExportModuleNotFound : ExportAllPagesResult() + data object NoPages : ExportAllPagesResult() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7dcac0eb..69cce1d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,8 @@ 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" [libraries] @@ -134,9 +136,12 @@ 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"} + [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } From 56572619eb8110673ef5052c569902426b4c756f Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 18 Aug 2026 19:24:42 +0200 Subject: [PATCH 12/16] Refactor modular structure: reorganize package namespaces --- .../scanbridge/AndroidCrashHandler.kt | 1 + .../AndroidExportCapabilitiesProvider.kt | 1 + .../chrisimx/scanbridge/MainActivity.kt | 1 - .../chrisimx/scanbridge/ScanBridgeApp.kt | 143 ------------------ .../scanbridge/ScanBridgeApplication.kt | 27 ++-- .../ScanJobForegroundService.kt | 5 +- .../LastRouteRepositoryAutoMigration.kt | 12 ++ .../migrations/MigrationRegistry.kt | 4 +- .../migrations/RoomBackedMigrationExecutor.kt | 2 +- .../DatastoreLastRouteRepository.kt | 2 +- .../chrisimx/scanbridge/CrashDisplay.kt | 2 +- .../chrisimx/scanbridge/ScanBridgeApp.kt | 82 ++++++++++ .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 30 ++-- .../chrisimx/scanbridge/ScanningScreen.kt | 14 +- .../uicomponents/ExportSettingsPopup.kt | 14 +- .../uicomponents/FoundScannerItem.kt | 2 +- .../uicomponents/StartupMessagesDisplay.kt | 78 ++++++++++ .../dialog/CustomScannerDialog.kt | 2 - .../uicomponents/settings/UIntSetting.kt | 2 +- ...roidMdnsDiscoverServiceInstrumentedTest.kt | 2 +- .../scanbridge/AndroidHttpClientFactory.kt | 2 +- .../scanbridge/AndroidMdnsDiscoverService.kt | 2 +- .../scanbridge/AndroidMulticastLockHandler.kt | 2 +- .../cropfeature/AndroidImageCropService.kt | 2 +- .../AndroidImageRotationService.kt | 2 +- .../ScanSettingsComposableStateHolder.kt | 2 +- .../cropfeature/FinishCropUseCase.kt | 2 +- .../db/migrations/RoomMigrationVersion5To6.kt | 2 +- .../db/migrations/RoomMigrationVersion6To7.kt | 2 +- .../export/ExportCapabilitiesProvider.kt | 2 +- .../scanbridge/export/KoinModuleExport.kt | 3 +- .../scanbridge/export/NoopExportModule.kt | 2 + .../imagerotation/RotateScanUseCase.kt | 2 +- .../scanbridge/koin/KoinModuleCommon.kt | 8 +- .../KmLogScanBridgeLogger.kt | 3 +- .../KmLogScanBridgeLoggerFactory.kt | 5 +- .../scanbridge/logging/KoinModuleLogging.kt | 10 ++ .../{ports => logging}/ScanBridgeLogger.kt | 2 +- .../ScanBridgeLoggerFactory.kt | 3 +- .../scanbridge/model/DiscoveredScanner.kt | 2 +- .../scanbridge/model/HttpClientConfig.kt | 2 +- .../chrisimx/scanbridge/model/ScanJob.kt | 2 +- .../scanbridge/model/ScannerHandle.kt | 6 +- .../KoinBasedScanningProtocolManager.kt | 5 +- .../{ => protocol}/ScanProtocolRegistry.kt | 7 +- .../{ports => protocol}/ScanningProtocol.kt | 5 +- .../ScanningProtocolManager.kt | 3 +- .../escl/EsclScannerDiscoveryBackend.kt | 8 +- .../escl/EsclScanningProtocol.kt | 14 +- .../wsd/WsdScannerDiscoveryBackend.kt | 4 +- .../{ => protocol}/wsd/WsdScanningProtocol.kt | 12 +- .../savelastroute/KoinModuleLastRouteStore.kt | 9 ++ .../LastRouteRepository.kt | 2 +- .../savelastroute}/RoomLastRouteRepository.kt | 12 +- .../CustomScannerRepository.kt | 4 +- .../scannerdiscovery/DiscoveryUsecase.kt | 4 +- .../RoomBackedCustomScannerRepository.kt | 5 +- .../ScannerDiscoveryScreenViewModel.kt | 5 +- .../ScanJobRepository.kt | 4 +- .../{ => scanning}/ScanningScreenViewModel.kt | 22 ++- .../scanbridge/scanning/StartScanUseCase.kt | 3 +- desktopApp/build.gradle.kts | 1 + .../src/main/kotlin/JvmPlatformKoinModule.kt | 5 + desktopApp/src/main/kotlin/Main.kt | 26 ++-- 64 files changed, 349 insertions(+), 307 deletions(-) delete mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApp.kt create mode 100644 androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/LastRouteRepositoryAutoMigration.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanBridgeApp.kt rename {androidApp/src/main/java => composeUI/src/commonMain/kotlin}/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt (80%) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/StartupMessagesDisplay.kt rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{infrastructure => logging}/KmLogScanBridgeLogger.kt (87%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{infrastructure => logging}/KmLogScanBridgeLoggerFactory.kt (59%) create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ports => logging}/ScanBridgeLogger.kt (93%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ports => logging}/ScanBridgeLoggerFactory.kt (63%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{adapters => protocol}/KoinBasedScanningProtocolManager.kt (83%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => protocol}/ScanProtocolRegistry.kt (54%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ports => protocol}/ScanningProtocol.kt (93%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ports => protocol}/ScanningProtocolManager.kt (81%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => protocol}/escl/EsclScannerDiscoveryBackend.kt (97%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => protocol}/escl/EsclScanningProtocol.kt (96%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => protocol}/wsd/WsdScannerDiscoveryBackend.kt (94%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => protocol}/wsd/WsdScanningProtocol.kt (94%) create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute/KoinModuleLastRouteStore.kt rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => savelastroute}/LastRouteRepository.kt (69%) rename {androidApp/src/main/java/io/github/chrisimx/scanbridge/repositories => core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastroute}/RoomLastRouteRepository.kt (52%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ports => scannerdiscovery}/CustomScannerRepository.kt (86%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{adapters => scannerdiscovery}/RoomBackedCustomScannerRepository.kt (86%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{repositories => scanning}/ScanJobRepository.kt (96%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => scanning}/ScanningScreenViewModel.kt (96%) create mode 100644 desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt index 61f4716f..333af815 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidCrashHandler.kt @@ -21,6 +21,7 @@ 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 diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt index e3540dad..0e7b236b 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/AndroidExportCapabilitiesProvider.kt @@ -1,6 +1,7 @@ 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/MainActivity.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt index dfeb1e7b..e49cbf55 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt @@ -33,7 +33,6 @@ 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 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 088a621d..9746d7ce 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -6,8 +6,8 @@ import coil3.ImageLoader import coil3.network.ktor3.KtorNetworkFetcherFactory import io.github.chrisimx.scanbridge.cropfeature.AndroidImageCropService 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.protocol.KoinBasedScanningProtocolManager +import io.github.chrisimx.scanbridge.scannerdiscovery.RoomBackedCustomScannerRepository import io.github.chrisimx.scanbridge.appsettings.AppSettingsRepository import io.github.chrisimx.scanbridge.appsettings.AppSettingsViewModel import io.github.chrisimx.scanbridge.appsettings.RoomAppSettingsRepository @@ -22,7 +22,7 @@ import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory 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.infrastructure.KmLogScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.KmLogScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.initialscansettings.DefaultInitialScanSettingsProvider import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.koin.KOIN_MODULE_COMMON @@ -32,25 +32,27 @@ 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.scannerdiscovery.CustomScannerRepository import io.github.chrisimx.scanbridge.ports.HttpClientFactory import io.github.chrisimx.scanbridge.localization.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.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.protocol.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.savelastroute.RoomLastRouteRepository import io.github.chrisimx.scanbridge.savelastusedscansettings.LastUsedScanSettingsRepository import io.github.chrisimx.scanbridge.savelastusedscansettings.RoomLastUsedScanSettingsRepository 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.repositories.ScanJobRepository +import io.github.chrisimx.scanbridge.scanning.ScanJobRepository +import io.github.chrisimx.scanbridge.savelastroute.LastRouteRepository import io.github.chrisimx.scanbridge.scan.AndroidScanExecutor import io.github.chrisimx.scanbridge.scanning.ScanExecutor +import io.github.chrisimx.scanbridge.scanning.ScanningScreenViewModel import io.github.chrisimx.scanbridge.startupmessages.RoomShownStartupMessagesRepository import io.github.chrisimx.scanbridge.startupmessages.ShownStartupMessagesRepository import io.ktor.client.HttpClient @@ -91,7 +93,7 @@ fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): } .build() } -val appModule = module { +val androidPlatformModule = module { single() bind Thread.UncaughtExceptionHandler::class single() bind LocaleProvider::class single() bind AppSettingsRepository::class @@ -110,7 +112,6 @@ val appModule = module { } factory() bind MdnsDiscoverService::class single() - single() bind LastRouteRepository::class single() bind LastUsedScanSettingsRepository::class single(named("legacyDatastoreShownMessages")) { DatastoreShownMessagesRepository( @@ -147,10 +148,6 @@ val appModule = module { single() bind ExportCapabilitiesProvider::class - includes( - KOIN_MODULE_COMMON - ) - includes(DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } @@ -159,7 +156,7 @@ class ScanBridgeApplication : Application() { super.onCreate() startKoin { androidContext(this@ScanBridgeApplication) - modules(appModule) + modules(KOIN_MODULE_COMMON, androidPlatformModule) } 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 779e6fa5..93c73dd9 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 @@ -21,9 +21,8 @@ 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.repositories.ScanJobRepository -import io.github.chrisimx.scanbridge.scanning.ScanExecutor +import io.github.chrisimx.scanbridge.protocol.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.scanning.ScanJobRepository import io.github.chrisimx.scanbridge.util.extractPdfImages import java.io.File import kotlin.jvm.java 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..9c485ced 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,8 @@ 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.LastRouteRepoAutoMigration +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/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt index 5c4036b0..8451421b 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CrashDisplay.kt @@ -20,7 +20,7 @@ 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.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource 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 80% 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 7f871c08..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,30 +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.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.json.Json import org.koin.compose.koinInject import scanbridge.composeui.generated.resources.Res import scanbridge.composeui.generated.resources.outline_error_24 -import timber.log.Timber +@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"), @@ -59,17 +62,13 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { } 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 } @@ -85,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/ScanningScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt index b11454d8..54cb3998 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -67,7 +67,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 @@ -87,7 +86,7 @@ import coil3.compose.AsyncImage 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.repositories.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 @@ -100,6 +99,10 @@ import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage 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 @@ -524,14 +527,13 @@ fun ScanningScreen( ) } } - var exportOptionsWidth by remember { mutableIntStateOf(0) } + val exportAlpha by animateFloatAsState( targetValue = if (showExportPopup) 1f else 0f, label = "alphaAnimationExportOptions", animationSpec = tween(300) ) - var saveOptionsWidth by remember { mutableIntStateOf(0) } val saveOptionsAlpha by animateFloatAsState( targetValue = if (showSavePopup) 1f else 0f, label = "alphaAnimationSaveOptions", @@ -563,10 +565,8 @@ fun ScanningScreen( if (exportAlpha > 0 && scanningViewModel.shareSupported) { ExportSettingsPopup( exportPositionAndHeight, - exportOptionsWidth, exportAlpha, onDismiss = { scanningViewModel.setShowExportOptionsPopup(false) }, - updateWidth = { exportOptionsWidth = it }, availableExportModuleTypes = availableExportModules, onExportModuleSelected = { selectedModule -> scanningViewModel.exportAllPages( @@ -580,10 +580,8 @@ fun ScanningScreen( if (saveOptionsAlpha > 0 && scanningViewModel.saveSupported) { ExportSettingsPopup( saveButtonPositionAndHeight, - saveOptionsWidth, saveOptionsAlpha, onDismiss = { scanningViewModel.setShowSaveOptionsPopup(false) }, - updateWidth = { exportOptionsWidth = it }, availableExportModuleTypes = availableExportModules, onExportModuleSelected = { selectedModule -> scanningViewModel.exportAllPages( 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 index 500cb55a..f5801e23 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt @@ -6,6 +6,11 @@ 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 @@ -27,17 +32,18 @@ import scanbridge.composeui.generated.resources.export_pdf @Composable fun ExportSettingsPopup( exportOptionsPopupPosition: PositionAndHeight?, - exportOptionsWidth: Int, alpha: Float, onDismiss: () -> Unit, - updateWidth: (Int) -> 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(exportOptionsWidth / 4) ?: 0, + exportOptionsPopupPosition?.x?.minus((thisOptionsWidth - oneItemsWidth) / 2 ) ?: 0, exportOptionsPopupPosition?.y?.minus(exportOptionsPopupPosition.height) ?: 0 ), onDismissRequest = { onDismiss() } @@ -45,7 +51,7 @@ fun ExportSettingsPopup( ElevatedCard( modifier = Modifier .onGloballyPositioned { coordinates -> - updateWidth(coordinates.size.width) + thisOptionsWidth = coordinates.size.width } .graphicsLayer { this.alpha = alpha diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index 7fb0b728..bb4f6c6d 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt @@ -48,7 +48,7 @@ import androidx.navigation.NavController import coil3.ImageLoader import coil3.compose.AsyncImage import io.github.chrisimx.scanbridge.ScannerRoute -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import org.jetbrains.compose.resources.DrawableResource 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/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/dialog/CustomScannerDialog.kt index 03a011b7..e579a594 100644 --- a/composeUI/src/commonMain/kotlin/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 @@ -24,7 +23,6 @@ import androidx.compose.ui.platform.testTag 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.model.EditedCustomScanner import io.github.chrisimx.scanbridge.model.UrlValidationResult import io.github.chrisimx.scanbridge.scannerdiscovery.ProtocolWithExampleHandleString diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt index 6ecd4242..7ed01ccc 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/UIntSetting.kt @@ -36,7 +36,7 @@ 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.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext diff --git a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt index 7eb5f01e..982a7b91 100644 --- a/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt +++ b/core/src/androidDeviceTest/kotlin/AndroidMdnsDiscoverServiceInstrumentedTest.kt @@ -5,7 +5,7 @@ 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/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt index 8c478856..cf882dec 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt @@ -2,7 +2,7 @@ package io.github.chrisimx.scanbridge import io.github.chrisimx.scanbridge.model.HttpClientConfig import io.github.chrisimx.scanbridge.ports.HttpClientFactory -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout diff --git a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt index f8bcfe8c..bcf658e1 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt @@ -9,7 +9,7 @@ import androidx.annotation.RequiresExtension 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 diff --git a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt index 9e1b3b34..04038ebb 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMulticastLockHandler.kt @@ -3,7 +3,7 @@ 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/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt index c13bfc99..53b095d6 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/AndroidImageCropService.kt @@ -3,7 +3,7 @@ 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.ports.ScanBridgeLoggerFactory +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 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 index 37ddc1e0..8f68e47d 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/AndroidImageRotationService.kt @@ -3,7 +3,7 @@ package io.github.chrisimx.scanbridge.imagerotation import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +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 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 b2d08297..6e073483 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt @@ -42,7 +42,7 @@ 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.localization.LocaleProvider -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory 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/cropfeature/FinishCropUseCase.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt index bbb27b4a..1a84e4bc 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/FinishCropUseCase.kt @@ -3,7 +3,7 @@ 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.ports.ScanBridgeLoggerFactory +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 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/ExportCapabilitiesProvider.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportCapabilitiesProvider.kt index 64ab02be..b267cb39 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportCapabilitiesProvider.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/ExportCapabilitiesProvider.kt @@ -1,6 +1,6 @@ package io.github.chrisimx.scanbridge.export -import io.github.chrisimx.scanbridge.FileSaveType +import io.github.chrisimx.scanbridge.scanning.FileSaveType interface ExportCapabilitiesProvider { val supportedFileSaveTypes: Set 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 index b35ec2ac..fbf2fd12 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/KoinModuleExport.kt @@ -8,8 +8,7 @@ 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 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 index db13199e..2e3c7503 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/export/NoopExportModule.kt @@ -10,3 +10,5 @@ open class NoopExportModule( throw NotImplementedError("No-op export module") } } + +class PdfNoopExportModule : NoopExportModule(ExportModuleType.PDF) 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 index 2211388d..50c8c409 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/imagerotation/RotateScanUseCase.kt @@ -1,7 +1,7 @@ package io.github.chrisimx.scanbridge.imagerotation import io.github.chrisimx.scanbridge.db.ScanBridgeDb -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory +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 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 index 6f3e5ade..da74b754 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt @@ -1,9 +1,11 @@ package io.github.chrisimx.scanbridge.koin -import io.github.chrisimx.scanbridge.KOIN_MODULE_SCAN_PROTOCOLS +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 org.koin.dsl.module @@ -13,6 +15,8 @@ val KOIN_MODULE_COMMON = module { KOIN_MODULE_EXPORT, KOIN_MODULE_SCAN_PROTOCOLS, KOIN_MODULE_ROOM_MIGRATIONS, - KOIN_MODULE_FILESYSTEM + KOIN_MODULE_FILESYSTEM, + KOIN_MODULE_LOGGING, + KOIN_MODULE_LAST_ROUTE_STORE ) } 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..5f0245c1 --- /dev/null +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt @@ -0,0 +1,10 @@ +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 + single() bind ScanBridgeLogger::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/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/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/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/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/ScanProtocolRegistry.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanProtocolRegistry.kt similarity index 54% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanProtocolRegistry.kt index 8bcb5607..30f4c060 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanProtocolRegistry.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/ScanProtocolRegistry.kt @@ -1,8 +1,7 @@ -package io.github.chrisimx.scanbridge +package io.github.chrisimx.scanbridge.protocol -import io.github.chrisimx.scanbridge.escl.EsclScanningProtocol -import io.github.chrisimx.scanbridge.ports.ScanningProtocol -import io.github.chrisimx.scanbridge.wsd.WsdScanningProtocol +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 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 97% 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..97437a35 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 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/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/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 5fc2ac7f..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 @@ -7,9 +7,8 @@ import com.rickclephas.kmp.observableviewmodel.stateIn import io.github.chrisimx.scanbridge.db.entities.CustomScanner import io.github.chrisimx.scanbridge.model.EditedCustomScanner import io.github.chrisimx.scanbridge.model.UrlValidationResult -import io.github.chrisimx.scanbridge.ports.CustomScannerRepository -import io.github.chrisimx.scanbridge.ports.ScanBridgeLoggerFactory -import io.github.chrisimx.scanbridge.ports.ScanningProtocolManager +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 diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/repositories/ScanJobRepository.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanJobRepository.kt similarity index 96% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/repositories/ScanJobRepository.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanJobRepository.kt index 54ea30d0..175383b1 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/repositories/ScanJobRepository.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanJobRepository.kt @@ -1,8 +1,8 @@ -package io.github.chrisimx.scanbridge.repositories +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.ports.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO diff --git a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanningScreenViewModel.kt similarity index 96% rename from core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt rename to core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanningScreenViewModel.kt index 4f80c933..ab5d869d 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreenViewModel.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanningScreenViewModel.kt @@ -17,7 +17,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package io.github.chrisimx.scanbridge +package io.github.chrisimx.scanbridge.scanning import androidx.room.immediateTransaction import androidx.room.useWriterConnection @@ -29,6 +29,7 @@ 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 @@ -42,18 +43,11 @@ import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProv 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.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.savelastusedscansettings.LastUsedScanSettingsRepository -import io.github.chrisimx.scanbridge.repositories.ScanJobRepository -import io.github.chrisimx.scanbridge.scanning.DeleteScanUseCase -import io.github.chrisimx.scanbridge.scanning.DeleteSessionUseCase -import io.github.chrisimx.scanbridge.scanning.ExportAllPagesResult -import io.github.chrisimx.scanbridge.scanning.ExportAllPagesUseCase import io.github.chrisimx.scanbridge.imagerotation.RotateScanUseCase -import io.github.chrisimx.scanbridge.scanning.StartScanUseCase -import io.github.chrisimx.scanbridge.scanning.SwapPagesUseCase import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.copyTo import io.github.vinceglb.filekit.path @@ -324,7 +318,11 @@ class ScanningScreenViewModel( localScanSettingsVM = ScanSettingsComposableStateHolder( MutableStateFlow(caps).asStateFlow(), session.map { it?.currentScanSettings ?: storedSession.currentScanSettings!! } - .stateIn(viewModelScope, SharingStarted.Lazily, storedSession.currentScanSettings!!), + .stateIn( + viewModelScope, + SharingStarted.Lazily, + storedSession.currentScanSettings!! + ), storedSession.currentSettingsUIData?.copy(), updateSettings, null, 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 index 2a7e6397..cfce28e1 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt @@ -7,8 +7,7 @@ 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.ports.ScannerConnectionSettings -import io.github.chrisimx.scanbridge.repositories.ScanJobRepository +import io.github.chrisimx.scanbridge.protocol.ScannerConnectionSettings import kotlin.uuid.Uuid class StartScanUseCase( diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 52c51045..1668c13a 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -7,6 +7,7 @@ plugins { } dependencies { implementation(project(":composeUI")) + implementation(project(":core")) } compose.desktop { diff --git a/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt b/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt new file mode 100644 index 00000000..475ee511 --- /dev/null +++ b/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt @@ -0,0 +1,5 @@ +import org.koin.dsl.module + +val KOIN_MODULE_JVM_PLATFORM = module { + +} diff --git a/desktopApp/src/main/kotlin/Main.kt b/desktopApp/src/main/kotlin/Main.kt index be251960..8d0b7770 100644 --- a/desktopApp/src/main/kotlin/Main.kt +++ b/desktopApp/src/main/kotlin/Main.kt @@ -2,16 +2,24 @@ 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 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() { + startKoin { + modules(KOIN_MODULE_JVM_PLATFORM, KOIN_MODULE_COMMON) + } + + application { + Window( + title = "ScanBridge", + state = rememberWindowState(width = 800.dp, height = 600.dp), + onCloseRequest = ::exitApplication + ) { + window.minimumSize = Dimension(350, 600) + ScanBridgeApp() + } } } From ebb61fa2c2a2b54b276f4cdea329468ddb59dfc8 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 1 Sep 2026 23:07:08 +0200 Subject: [PATCH 13/16] Implement JVM port --- androidApp/build.gradle.kts | 14 +- .../scanbridge/AndroidBuildInfoProvider.kt | 3 - .../chrisimx/scanbridge/MainActivity.kt | 2 +- .../scanbridge/ScanBridgeApplication.kt | 69 ++------ .../ScanJobForegroundService.kt | 138 +--------------- .../migrations/MigrationRegistry.kt | 1 - ...r.kt => AndroidScanExecutionEntryPoint.kt} | 6 +- build.gradle.kts | 4 + .../composeResources/values/strings.xml | 1 + .../uicomponents/SelectionButtonRow.kt | 2 +- .../settings/VersionComposable.kt | 2 + core/build.gradle.kts | 25 ++- .../scanbridge/AndroidMdnsDiscoverService.kt | 1 + .../chrisimx/scanbridge/util/PdfUtil.apple.kt | 20 +++ .../ScanSettingsComposableStateHolder.kt | 2 + .../appsettings/KoinModuleAppSettings.kt | 11 ++ .../scanbridge/buildinfo/BuildInfoProvider.kt | 4 + .../scanbridge/buildinfo/ScanBridgeEdition.kt | 3 +- .../cropfeature/KoinModuleCropFeature.kt | 11 ++ .../scanbridge/db/KoinModuleDatabase.kt | 15 ++ .../KoinModuleInitialScanSettings.kt | 9 ++ .../scanbridge/koin/KoinModuleCommon.kt | 24 ++- .../scanbridge/logging/KoinModuleLogging.kt | 1 - .../model/ScanSettingsEnterableData.kt | 4 +- .../paperformat/KoinModulePaperFormat.kt | 9 ++ .../{ => paperformat}/PaperFormat.kt | 2 +- .../{ => paperformat}/PaperFormatProvider.kt | 2 +- .../KoinModuleScanningProtocolFeature.kt | 9 ++ .../KoinModuleLastUsedScanSettingsStore.kt | 9 ++ .../KoinModuleScannerDiscovery.kt | 12 ++ .../scanbridge/scanning/CommonScanExecutor.kt | 151 ++++++++++++++++++ .../scanning/KoinModuleScanJobManagement.kt | 10 ++ .../scanning/KoinModuleScanningScreen.kt | 11 ++ .../scanning/ScanExecutionEntryPoint.kt | 5 + .../scanbridge/scanning/ScanExecutor.kt | 2 +- .../scanbridge/scanning/StartScanUseCase.kt | 4 +- .../KoinModuleStartupMessages.kt | 9 ++ .../chrisimx/scanbridge/util/PdfUtil.kt | 6 + .../chrisimx/scanbridge/util/ResultUtil.kt | 12 ++ core/src/commonTest/kotlin/PaperFormatTest.kt | 2 +- .../kotlin}/InetAddressKMPConverter.kt | 2 - .../kotlin/JvmHttpClientFactory.kt} | 8 +- .../kotlin/JvmImageCropService.kt | 50 ++++++ .../kotlin/KoinModuleJvmAndAndroidPlatform.kt | 14 ++ .../kotlin}/TrustAllTrustManager.kt | 5 - .../localization/JvmLocaleProvider.kt | 16 +- .../scanbridge/util/PdfUtil.jvmAndAndroid.kt | 61 +++++++ .../jvmMain/kotlin/JvmMdnsDiscoverService.kt | 77 +++++++++ .../db/JvmScanBridgeDbBuilderFactory.kt | 18 +++ desktopApp/build.gradle.kts | 12 +- .../kotlin/DesktopJvmBuildInfoProvider.kt | 12 ++ .../DesktopJvmExportCapabilitiesProvider.kt | 6 + .../kotlin/DesktopJvmImageRotationService.kt | 59 +++++++ .../kotlin/DesktopJvmMulticastLockHandler.kt | 7 + .../DesktopJvmScanExecutionEntryPoint.kt | 18 +++ .../src/main/kotlin/JvmPlatformKoinModule.kt | 27 ++++ desktopApp/src/main/kotlin/Main.kt | 4 +- .../src/main/kotlin/StartupTabDefinitions.kt | 50 ++++++ gradle.properties | 5 +- gradle/libs.versions.toml | 16 +- 60 files changed, 845 insertions(+), 249 deletions(-) rename androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/{AndroidScanExecutor.kt => AndroidScanExecutionEntryPoint.kt} (66%) create mode 100644 core/src/appleMain/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.apple.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/appsettings/KoinModuleAppSettings.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/cropfeature/KoinModuleCropFeature.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/db/KoinModuleDatabase.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/initialscansettings/KoinModuleInitialScanSettings.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/paperformat/KoinModulePaperFormat.kt rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => paperformat}/PaperFormat.kt (97%) rename core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/{ => paperformat}/PaperFormatProvider.kt (88%) create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/KoinModuleScanningProtocolFeature.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/savelastusedscansettings/KoinModuleLastUsedScanSettingsStore.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scannerdiscovery/KoinModuleScannerDiscovery.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/CommonScanExecutor.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanJobManagement.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/KoinModuleScanningScreen.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutionEntryPoint.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/startupmessages/KoinModuleStartupMessages.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.kt create mode 100644 core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/util/ResultUtil.kt rename core/src/{androidMain/kotlin/io/github/chrisimx/scanbridge => jvmAndAndroid/kotlin}/InetAddressKMPConverter.kt (93%) rename core/src/{androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt => jvmAndAndroid/kotlin/JvmHttpClientFactory.kt} (87%) create mode 100644 core/src/jvmAndAndroid/kotlin/JvmImageCropService.kt create mode 100644 core/src/jvmAndAndroid/kotlin/KoinModuleJvmAndAndroidPlatform.kt rename core/src/{androidMain/kotlin/io/github/chrisimx/scanbridge => jvmAndAndroid/kotlin}/TrustAllTrustManager.kt (80%) rename androidApp/src/main/java/io/github/chrisimx/scanbridge/services/AndroidLocaleProvider.kt => core/src/jvmAndAndroid/kotlin/io/github/chrisimx/localization/JvmLocaleProvider.kt (63%) create mode 100644 core/src/jvmAndAndroid/kotlin/io/github/chrisimx/scanbridge/util/PdfUtil.jvmAndAndroid.kt create mode 100644 core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt create mode 100644 core/src/jvmMain/kotlin/io/github/chrisimx/db/JvmScanBridgeDbBuilderFactory.kt create mode 100644 desktopApp/src/main/kotlin/DesktopJvmBuildInfoProvider.kt create mode 100644 desktopApp/src/main/kotlin/DesktopJvmExportCapabilitiesProvider.kt create mode 100644 desktopApp/src/main/kotlin/DesktopJvmImageRotationService.kt create mode 100644 desktopApp/src/main/kotlin/DesktopJvmMulticastLockHandler.kt create mode 100644 desktopApp/src/main/kotlin/DesktopJvmScanExecutionEntryPoint.kt create mode 100644 desktopApp/src/main/kotlin/StartupTabDefinitions.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index e10c8088..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") @@ -41,8 +36,8 @@ android { applicationId = "io.github.chrisimx.scanbridge" minSdk = 28 targetSdk = 37 - versionCode = 2_001_005 // we just count up for each build - versionName = "2.2.0-alpha1" + 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" 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/MainActivity.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt index e49cbf55..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,7 +26,7 @@ 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 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 9746d7ce..8d12e4aa 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -1,59 +1,33 @@ package io.github.chrisimx.scanbridge +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.scanbridge.cropfeature.AndroidImageCropService -import io.github.chrisimx.localization.JvmNumberFormatter -import io.github.chrisimx.scanbridge.protocol.KoinBasedScanningProtocolManager -import io.github.chrisimx.scanbridge.scannerdiscovery.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.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.DefaultScanBridgeDbFactory -import io.github.chrisimx.scanbridge.db.ScanBridgeDb import io.github.chrisimx.scanbridge.db.ScanBridgeDbBuilderFactory -import io.github.chrisimx.scanbridge.db.ScanBridgeDbFactory import io.github.chrisimx.scanbridge.export.ExportCapabilitiesProvider import io.github.chrisimx.scanbridge.imagerotation.AndroidImageRotationService import io.github.chrisimx.scanbridge.imagerotation.ImageRotationService -import io.github.chrisimx.scanbridge.logging.KmLogScanBridgeLoggerFactory -import io.github.chrisimx.scanbridge.initialscansettings.DefaultInitialScanSettingsProvider -import io.github.chrisimx.scanbridge.initialscansettings.InitialScanSettingsProvider import io.github.chrisimx.scanbridge.koin.KOIN_MODULE_COMMON -import io.github.chrisimx.scanbridge.localization.NumberFormatter 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.scannerdiscovery.CustomScannerRepository import io.github.chrisimx.scanbridge.ports.HttpClientFactory -import io.github.chrisimx.scanbridge.localization.LocaleProvider import io.github.chrisimx.scanbridge.ports.MdnsDiscoverService -import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory -import io.github.chrisimx.scanbridge.protocol.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.savelastroute.RoomLastRouteRepository -import io.github.chrisimx.scanbridge.savelastusedscansettings.LastUsedScanSettingsRepository -import io.github.chrisimx.scanbridge.savelastusedscansettings.RoomLastUsedScanSettingsRepository -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.scanning.ScanJobRepository -import io.github.chrisimx.scanbridge.savelastroute.LastRouteRepository -import io.github.chrisimx.scanbridge.scan.AndroidScanExecutor -import io.github.chrisimx.scanbridge.scanning.ScanExecutor -import io.github.chrisimx.scanbridge.scanning.ScanningScreenViewModel -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.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope @@ -71,7 +45,6 @@ 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( @@ -95,59 +68,37 @@ fun createScannerIconImageLoader(factory: HttpClientFactory, context: Context): } val androidPlatformModule = module { single() bind Thread.UncaughtExceptionHandler::class - single() bind LocaleProvider::class - single() bind AppSettingsRepository::class - single() bind HttpClientFactory::class - single() bind ScanBridgeLoggerFactory::class - single() 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 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 ScanExecutor::class + single() bind ScanExecutionEntryPoint::class + single() bind ImageRotationService::class single() bind BuildInfoProvider::class - viewModel() - - single() bind NumberFormatter::class single { STARTUP_TABS_PROVIDER } - viewModel() - single() bind ImageCropService::class - single() - single() bind ExportCapabilitiesProvider::class + single() bind ImageCropService::class + includes(DATASTORE_TO_ROOM_MIGRATION_DATA_SOURCES) } @@ -156,7 +107,7 @@ class ScanBridgeApplication : Application() { super.onCreate() startKoin { androidContext(this@ScanBridgeApplication) - modules(KOIN_MODULE_COMMON, androidPlatformModule) + modules(KOIN_MODULE_COMMON, androidPlatformModule, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM) } 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 93c73dd9..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.protocol.ScanJobProcessingEvent +import io.github.chrisimx.scanbridge.scanning.ScanExecutor import io.github.chrisimx.scanbridge.scanning.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 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/migrations/MigrationRegistry.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/migrations/MigrationRegistry.kt index 9c485ced..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,7 +1,6 @@ package io.github.chrisimx.scanbridge.migrations import io.github.chrisimx.scanbridge.repositories.DatastoreLastRouteRepository -import io.github.chrisimx.scanbridge.savelastroute.LastRouteRepoAutoMigration import io.github.chrisimx.scanbridge.savelastroute.RoomLastRouteRepository import org.koin.dsl.bind import org.koin.dsl.module diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutor.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutionEntryPoint.kt similarity index 66% rename from androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutor.kt rename to androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutionEntryPoint.kt index 6446a292..e9a9e5e5 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutor.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/scan/AndroidScanExecutionEntryPoint.kt @@ -2,11 +2,11 @@ package io.github.chrisimx.scanbridge.scan import android.app.Application import io.github.chrisimx.scanbridge.androidservice.ScanJobForegroundService -import io.github.chrisimx.scanbridge.scanning.ScanExecutor +import io.github.chrisimx.scanbridge.scanning.ScanExecutionEntryPoint -class AndroidScanExecutor( +class AndroidScanExecutionEntryPoint( val application: Application -) : ScanExecutor { +) : ScanExecutionEntryPoint { override fun start() { ScanJobForegroundService.startService(application) } 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/src/commonMain/composeResources/values/strings.xml b/composeUI/src/commonMain/composeResources/values/strings.xml index 59999a2b..ee5a994d 100644 --- a/composeUI/src/commonMain/composeResources/values/strings.xml +++ b/composeUI/src/commonMain/composeResources/values/strings.xml @@ -186,4 +186,5 @@ 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/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/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt index 33c2471b..18489a75 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/settings/VersionComposable.kt @@ -44,6 +44,7 @@ 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 @@ -54,6 +55,7 @@ fun ScanBridgeEdition.toLocalizedString(): String = when (this) { 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 diff --git a/core/build.gradle.kts b/core/build.gradle.kts index de8b387b..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 { @@ -53,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) @@ -77,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) @@ -97,6 +104,7 @@ kotlin { } jvmMain.dependencies { + implementation(libs.jmdns) } } @@ -127,3 +135,18 @@ dependencies { 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/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt index bcf658e1..aa1cac4c 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt +++ b/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidMdnsDiscoverService.kt @@ -6,6 +6,7 @@ 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 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/ScanSettingsComposableStateHolder.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt index 6e073483..0a18be92 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanSettingsComposableStateHolder.kt @@ -43,6 +43,8 @@ import io.github.chrisimx.scanbridge.model.UIInputSourceType import io.github.chrisimx.scanbridge.model.toUIInputSourceType 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/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/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 index da74b754..f4806dd6 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinModuleCommon.kt @@ -1,5 +1,7 @@ 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 @@ -7,16 +9,36 @@ 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_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/logging/KoinModuleLogging.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt index 5f0245c1..2a92e499 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/logging/KoinModuleLogging.kt @@ -6,5 +6,4 @@ import org.koin.plugin.module.dsl.single val KOIN_MODULE_LOGGING = module { single() bind ScanBridgeLoggerFactory::class - single() bind ScanBridgeLogger::class } 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/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/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/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/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/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/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/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 index 8dbf0d22..37c2ffd0 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutor.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/ScanExecutor.kt @@ -1,5 +1,5 @@ package io.github.chrisimx.scanbridge.scanning interface ScanExecutor { - fun start() + suspend fun executeScans() } 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 index cfce28e1..c7f98d99 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/scanning/StartScanUseCase.kt @@ -12,7 +12,7 @@ import kotlin.uuid.Uuid class StartScanUseCase( val scanJobRepo: ScanJobRepository, - val scanExecutor: ScanExecutor + val scanExecutionEntryPoint: ScanExecutionEntryPoint ) { fun startScan( ownerSessionId: Uuid, @@ -29,7 +29,7 @@ class StartScanUseCase( ) scanJobRepo.enqueue(scanJob) - scanExecutor.start() + scanExecutionEntryPoint.start() } /** 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/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/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/io/github/chrisimx/scanbridge/InetAddressKMPConverter.kt b/core/src/jvmAndAndroid/kotlin/InetAddressKMPConverter.kt similarity index 93% rename from core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/InetAddressKMPConverter.kt rename to core/src/jvmAndAndroid/kotlin/InetAddressKMPConverter.kt index 33f32164..350ffdfe 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/InetAddressKMPConverter.kt +++ b/core/src/jvmAndAndroid/kotlin/InetAddressKMPConverter.kt @@ -1,5 +1,3 @@ -package io.github.chrisimx.scanbridge - import io.github.chrisimx.scanbridge.model.IpAddress import java.net.InetAddress diff --git a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt b/core/src/jvmAndAndroid/kotlin/JvmHttpClientFactory.kt similarity index 87% rename from core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt rename to core/src/jvmAndAndroid/kotlin/JvmHttpClientFactory.kt index cf882dec..bf40c30d 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/AndroidHttpClientFactory.kt +++ b/core/src/jvmAndAndroid/kotlin/JvmHttpClientFactory.kt @@ -1,16 +1,14 @@ -package io.github.chrisimx.scanbridge - +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.logging.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/io/github/chrisimx/scanbridge/TrustAllTrustManager.kt b/core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt similarity index 80% rename from core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TrustAllTrustManager.kt rename to core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt index c46441f2..6d74fd60 100644 --- a/core/src/androidMain/kotlin/io/github/chrisimx/scanbridge/TrustAllTrustManager.kt +++ b/core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt @@ -1,6 +1,4 @@ -package io.github.chrisimx.scanbridge -import android.annotation.SuppressLint import java.security.SecureRandom import java.security.cert.X509Certificate import javax.net.ssl.SSLContext @@ -8,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() } 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 63% 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 a14ba25c..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.model.Locale import io.github.chrisimx.scanbridge.localization.LocaleProvider +import io.github.chrisimx.scanbridge.logging.ScanBridgeLoggerFactory +import io.github.chrisimx.scanbridge.model.Locale 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..ab0973d9 --- /dev/null +++ b/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt @@ -0,0 +1,77 @@ +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 : MdnsDiscoverService { + 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)) { + return + } + + jmdns.addServiceListener(serviceType, object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) {} + + override fun serviceRemoved(event: ServiceEvent) { + _foundServices.update { + it.filterKeys { key -> key != event.name } + } + } + + override fun serviceResolved(event: ServiceEvent) { + 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 + } + + jmdns.unregisterAllServices() + _serviceType = null + } + + override fun close() { + 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 1668c13a..c316ab33 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -4,10 +4,20 @@ 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) } compose.desktop { @@ -17,7 +27,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 index 475ee511..aaa9e41e 100644 --- a/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt +++ b/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt @@ -1,5 +1,32 @@ +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 } diff --git a/desktopApp/src/main/kotlin/Main.kt b/desktopApp/src/main/kotlin/Main.kt index 8d0b7770..904fbdbc 100644 --- a/desktopApp/src/main/kotlin/Main.kt +++ b/desktopApp/src/main/kotlin/Main.kt @@ -4,12 +4,14 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import io.github.chrisimx.scanbridge.ScanBridgeApp import io.github.chrisimx.scanbridge.koin.KOIN_MODULE_COMMON +import io.github.vinceglb.filekit.FileKit import java.awt.Dimension import org.koin.core.context.startKoin fun main() { + FileKit.init("io.github.chrisimx.scanbridge") startKoin { - modules(KOIN_MODULE_JVM_PLATFORM, KOIN_MODULE_COMMON) + modules(KOIN_MODULE_JVM_PLATFORM, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM, KOIN_MODULE_COMMON) } application { 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 69cce1d9..7633545d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,6 @@ 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" @@ -52,6 +51,10 @@ 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] androidx-concurrent-futures = { module = "androidx.concurrent:concurrent-futures", version.ref = "concurrentFutures" } @@ -76,6 +79,7 @@ constraintlayout-compose-multiplatform = { module = "tech.annexflow.compose:cons 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" } @@ -93,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" } @@ -126,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" } @@ -141,6 +146,8 @@ kmp-native-zip = { module = "io.github.chrisimx:kmp-native-zip", version.ref = " 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] @@ -159,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 From cdf0877ffbdd7416dfea7412e15cac70f928006c Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 4 Sep 2026 09:33:30 +0200 Subject: [PATCH 14/16] Create common scanner icon image loader --- .../scanbridge/ScanBridgeApplication.kt | 28 ++--------------- composeUI/build.gradle.kts | 1 + .../scanbridge/ScannerIconImageLoader.kt | 30 +++++++++++++++++++ .../koin/KoinComposeUICommonModule.kt | 12 ++++++++ .../src/main/kotlin/JvmPlatformKoinModule.kt | 5 ++++ desktopApp/src/main/kotlin/Main.kt | 3 +- 6 files changed, 53 insertions(+), 26 deletions(-) create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScannerIconImageLoader.kt create mode 100644 composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/koin/KoinComposeUICommonModule.kt diff --git a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt index 8d12e4aa..0ab60811 100644 --- a/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt +++ b/androidApp/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeApplication.kt @@ -15,6 +15,7 @@ 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 @@ -45,35 +46,12 @@ import org.koin.plugin.module.dsl.single import org.koin.plugin.module.dsl.viewModel import timber.log.Timber - -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 androidPlatformModule = module { single() bind Thread.UncaughtExceptionHandler::class single() bind MigrationExecutor::class includes(migrationsModule) single() bind ScanBridgeDbBuilderFactory::class - single(named("scannerIconImageLoader")) { - create(::createScannerIconImageLoader) - } + factory() bind MdnsDiscoverService::class single() single(named("legacyDatastoreShownMessages")) { @@ -107,7 +85,7 @@ class ScanBridgeApplication : Application() { super.onCreate() startKoin { androidContext(this@ScanBridgeApplication) - modules(KOIN_MODULE_COMMON, androidPlatformModule, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM) + modules(KOIN_MODULE_COMMON, androidPlatformModule, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM, KOIN_MODULE_COMPOSE_UI_COMMON) } Timber.plant(Timber.DebugTree()) diff --git a/composeUI/build.gradle.kts b/composeUI/build.gradle.kts index 8afebee8..d99d95c0 100644 --- a/composeUI/build.gradle.kts +++ b/composeUI/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.compose.multiplatform) alias(libs.plugins.android.kmp.library) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.koin) } kotlin { 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/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/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt b/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt index aaa9e41e..1e47ed8d 100644 --- a/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt +++ b/desktopApp/src/main/kotlin/JvmPlatformKoinModule.kt @@ -1,3 +1,4 @@ +import coil3.PlatformContext import io.github.chrisimx.db.JvmScanBridgeDbBuilderFactory import io.github.chrisimx.scanbridge.StartupTabsProvider import io.github.chrisimx.scanbridge.buildinfo.BuildInfoProvider @@ -29,4 +30,8 @@ val KOIN_MODULE_JVM_PLATFORM = module { 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 904fbdbc..b38aa892 100644 --- a/desktopApp/src/main/kotlin/Main.kt +++ b/desktopApp/src/main/kotlin/Main.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState 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 @@ -11,7 +12,7 @@ import org.koin.core.context.startKoin fun main() { FileKit.init("io.github.chrisimx.scanbridge") startKoin { - modules(KOIN_MODULE_JVM_PLATFORM, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM, KOIN_MODULE_COMMON) + modules(KOIN_MODULE_JVM_PLATFORM, KOIN_MODULE_JVM_AND_ANDROID_PLATFORM, KOIN_MODULE_COMMON, KOIN_MODULE_COMPOSE_UI_COMMON) } application { From 840f359d60bfd00b2a9cf7dfeea8e276b881d46f Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Fri, 4 Sep 2026 14:49:32 +0200 Subject: [PATCH 15/16] Back navigation on Desktop and iOS --- .../PlatformBackHandler.apple.kt | 9 ++++++++- .../github/chrisimx/scanbridge/CropScreen.kt | 9 +++++---- .../chrisimx/scanbridge/ScanningScreen.kt | 10 +++++----- .../platformhelper/PlatformBackHandler.jvm.kt | 8 +++++++- .../escl/EsclScannerDiscoveryBackend.kt | 4 ++-- .../kotlin/TrustAllTrustManager.kt | 2 +- .../jvmMain/kotlin/JvmMdnsDiscoverService.kt | 20 +++++++++++++++++-- 7 files changed, 46 insertions(+), 16 deletions(-) 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 index 064f5f49..9f92fc32 100644 --- 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 @@ -1,11 +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) { - BackHandler(enabled, onBack) + IconButton(onClick = onBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } } diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt index 0f2622b1..6a13c657 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/CropScreen.kt @@ -64,10 +64,6 @@ fun CropScreen( val croppedImageFilePath by viewModel.croppedImageFilePath.collectAsState() val cropState by viewModel.cropState.collectAsState() - PlatformBackHandler { - navController.clearAndNavigateTo(returnRoute) - } - LaunchedEffect(cropState) { if (cropState is CropState.Finished) { navController.clearAndNavigateTo(returnRoute) @@ -76,6 +72,11 @@ fun CropScreen( Scaffold( modifier = Modifier.fillMaxSize(), + topBar = { + PlatformBackHandler { + navController.clearAndNavigateTo(returnRoute) + } + }, floatingActionButton = { ExtendedFloatingActionButton( onClick = { diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt index 54cb3998..e3909dbe 100644 --- a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -406,10 +406,6 @@ fun ScanningScreen( } } - PlatformBackHandler { - scanningViewModel.leaveRequested() - } - if (confirmLeaveDialogShown) { ConfirmCloseDialog( onDismiss = { scanningViewModel.onLeaveDismissed() }, @@ -470,7 +466,11 @@ fun ScanningScreen( } } }, - topBar = { }, + topBar = { + PlatformBackHandler { + scanningViewModel.leaveRequested() + } + }, bottomBar = { if (isReadyForScans) { ScanningScreenBottomBar( 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 index feaa3caf..9e741484 100644 --- 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 @@ -1,8 +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/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScannerDiscoveryBackend.kt b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScannerDiscoveryBackend.kt index 97437a35..ba600f39 100644 --- a/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScannerDiscoveryBackend.kt +++ b/core/src/commonMain/kotlin/io/github/chrisimx/scanbridge/protocol/escl/EsclScannerDiscoveryBackend.kt @@ -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/jvmAndAndroid/kotlin/TrustAllTrustManager.kt b/core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt index 6d74fd60..6a86e476 100644 --- a/core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt +++ b/core/src/jvmAndAndroid/kotlin/TrustAllTrustManager.kt @@ -18,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/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt b/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt index ab0973d9..11153065 100644 --- a/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt +++ b/core/src/jvmMain/kotlin/JvmMdnsDiscoverService.kt @@ -1,3 +1,4 @@ +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 @@ -10,7 +11,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -class JvmMdnsDiscoverService : MdnsDiscoverService { +class JvmMdnsDiscoverService( + loggerFactory: ScanBridgeLoggerFactory +) : MdnsDiscoverService { + val logger = loggerFactory.withClass(this::class) + var _serviceType: String? = null override val serviceType: String? get() = _serviceType @@ -32,19 +37,27 @@ class JvmMdnsDiscoverService : MdnsDiscoverService { 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) {} + 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, @@ -67,11 +80,14 @@ class JvmMdnsDiscoverService : MdnsDiscoverService { return } + logger.debug { "Stopping JvmMdnsDiscoverService" } + jmdns.unregisterAllServices() _serviceType = null } override fun close() { + logger.debug { "Closing JvmMdnsDiscoverService" } jmdns.close() } } From d29da96ce7a4750c346d3bd8e1174bc40a6904d9 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 15 Sep 2026 17:14:26 +0200 Subject: [PATCH 16/16] Ensure consistent icon size for found scanner items --- .../chrisimx/scanbridge/uicomponents/FoundScannerItem.kt | 3 +++ desktopApp/build.gradle.kts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt b/composeUI/src/commonMain/kotlin/io/github/chrisimx/scanbridge/uicomponents/FoundScannerItem.kt index bb4f6c6d..724d9720 100644 --- a/composeUI/src/commonMain/kotlin/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 @@ -137,6 +138,7 @@ fun FoundScannerItem( imageLoader = imageLoader, modifier = Modifier .align(Alignment.CenterVertically) + .size(70.dp) .padding(17.dp), placeholder = tintedPlaceholder, error = tintedPlaceholder, @@ -151,6 +153,7 @@ fun FoundScannerItem( Icon( modifier = Modifier .align(Alignment.CenterVertically) + .size(70.dp) .padding(17.dp), painter = painterResource(Res.drawable.round_print_36), tint = MaterialTheme.colorScheme.surfaceTint, diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index c316ab33..9fd68591 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -20,6 +20,8 @@ dependencies { runtimeOnly(libs.logback) } +kotlin { jvmToolchain(21) } + compose.desktop { application { mainClass = "MainKt"