From 799efa861d2b86f4fe38368274dc4625c14dc43b Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 16 Jun 2025 15:27:30 +0200 Subject: [PATCH 01/19] [UNTIL-14107] started migration to KMP --- .editorconfig | 54 -- .gitignore | 9 +- build.gradle.kts | 13 +- gradle.properties | 36 +- gradle/libs.versions.toml | 174 +--- gradle/wrapper/gradle-wrapper.properties | 6 +- jitpack.yml | 5 - sample/build.gradle.kts | 60 +- sample/src/main/AndroidManifest.xml | 10 +- .../tillhub/scanengine/sample/MainActivity.kt | 314 +------ .../scanengine/sample/ui/theme/Theme.kt | 26 +- .../drawable-v24/ic_launcher_foreground.xml | 6 - .../res/drawable/ic_launcher_background.xml | 162 +++- .../res/drawable/ic_launcher_foreground.xml | 30 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 1 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 461 -> 0 bytes .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 368 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 482 -> 0 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 683 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 836 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes sample/src/main/res/values/colors.xml | 2 - sample/src/main/res/values/strings.xml | 8 - sample/src/main/res/values/styles.xml | 4 + scan-engine/.gitignore | 1 - scan-engine/build.gradle.kts | 112 --- scan-engine/config/detekt.yml | 774 ------------------ scan-engine/consumer-rules.pro | 0 ...code_scanner_library_v2.6.22.0-release.jar | Bin 391435 -> 0 bytes scan-engine/proguard-rules.pro | 42 - .../barcode/SunmiBarcodeScannerTest.kt | 56 -- .../google/ui/GoogleScanningActivityTest.kt | 65 -- scan-engine/src/main/AndroidManifest.xml | 42 - .../java/de/tillhub/scanengine/ScanEngine.kt | 48 -- .../barcode/BarcodeScannerContainer.kt | 98 --- .../barcode/BarcodeScannerFactory.kt | 21 - .../scanengine/barcode/BarcodeScannerImpl.kt | 19 - .../scanengine/common/SingletonHolder.kt | 28 - .../scanengine/common/StandardExtensions.kt | 5 - .../scanengine/extensions/TabletExtensions.kt | 23 - .../generic/GenericKeyEventScanner.kt | 80 -- .../scanengine/google/DefaultCameraScanner.kt | 37 - .../google/DefaultScannerActivityContract.kt | 21 - .../google/ui/GoogleScanningActivity.kt | 154 ---- .../google/ui/GoogleScanningViewModel.kt | 72 -- .../sunmi/barcode/SunmiBarcodeScanner.kt | 57 -- .../sunmi/camera/SunmiCameraScanner.kt | 50 -- .../camera/SunmiScannerActivityContract.kt | 78 -- .../java/de/tillhub/scanengine/theme/Color.kt | 5 - .../scanengine/theme/ScanEngineTheme.kt | 47 -- .../java/de/tillhub/scanengine/theme/Style.kt | 13 - .../scanengine/zebra/ZebraBarcodeScanner.kt | 237 ------ .../zebra/ZebraPairBarcodeActivity.kt | 313 ------- .../zebra/ZebraPairBarcodeViewModel.kt | 54 -- .../res/drawable/classic_discoverable.png | Bin 2822 -> 0 bytes .../src/main/res/font/open_sans_semi_bold.ttf | Bin 100820 -> 0 bytes .../res/layout/activity_google_scanning.xml | 31 - .../src/main/res/values-de/strings.xml | 14 - .../src/main/res/values-w600dp/themes.xml | 17 - scan-engine/src/main/res/values/colors.xml | 4 - scan-engine/src/main/res/values/strings.xml | 15 - scan-engine/src/main/res/values/styles.xml | 28 - scan-engine/src/main/res/values/themes.xml | 7 - .../de/tillhub/scanengine/ScanEngineTest.kt | 62 -- .../barcode/BarcodeScannerContainerTest.kt | 130 --- .../scanengine/common/InstantTaskExecutor.kt | 33 - .../scanengine/common/ViewModelFunSpec.kt | 15 - .../scanengine/common/ViewModelListener.kt | 27 - .../data/ScannerManufacturerTest.kt | 20 - .../generic/GenericKeyEventScannerTest.kt | 216 ----- .../google/DefaultCameraScannerTest.kt | 95 --- .../google/ui/GoogleScanningViewModelTest.kt | 93 --- .../sunmi/SunmiCameraScannerTest.kt | 94 --- .../zebra/ZebraPairBarcodeViewModelTest.kt | 66 -- settings.gradle.kts | 15 +- shared/build.gradle.kts | 66 ++ shared/src/androidMain/AndroidManifest.xml | 14 + .../de/tillhub/scanengine/Platform.android.kt | 8 + .../camera/AndroidPermissionHandler.kt | 61 ++ .../camera/CameraController.android.kt | 140 ++++ .../camera/ui/CameraScreen.android.kt | 42 + .../de/tillhub/scanengine/BarcodeScanner.kt | 0 .../de/tillhub/scanengine/CameraScanner.kt | 0 .../de/tillhub/scanengine/KeyEventScanner.kt | 2 +- .../kotlin/de/tillhub/scanengine/Platform.kt | 8 + .../de/tillhub/scanengine/ScanEngine.kt | 28 + .../scanengine/camera/CameraController.kt | 15 + .../scanengine/camera/PermissionHandler.kt | 24 + .../scanengine/camera/ui/CameraScreen.kt | 43 + .../scanengine/common/SingletonHolder.kt | 32 + .../de/tillhub/scanengine/data/Scanner.kt | 0 .../tillhub/scanengine/data/ScannerEvent.kt | 0 .../scanengine/data/ScannerResponse.kt | 3 +- .../de/tillhub/scanengine/data/ScannerType.kt | 4 +- .../de/tillhub/scanengine/Platform.ios.kt | 10 + .../scanengine/camera/CameraController.ios.kt | 18 + .../scanengine/camera/IosPermissionHandler.kt | 37 + .../scanengine/camera/ui/CameraScreen.ios.kt | 15 + 108 files changed, 899 insertions(+), 4126 deletions(-) delete mode 100644 .editorconfig delete mode 100644 jitpack.yml delete mode 100644 sample/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 sample/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 sample/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 sample/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 sample/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 sample/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 sample/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 sample/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 sample/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp delete mode 100644 sample/src/main/res/values/colors.xml delete mode 100644 sample/src/main/res/values/strings.xml create mode 100644 sample/src/main/res/values/styles.xml delete mode 100644 scan-engine/.gitignore delete mode 100644 scan-engine/build.gradle.kts delete mode 100644 scan-engine/config/detekt.yml delete mode 100644 scan-engine/consumer-rules.pro delete mode 100644 scan-engine/libs/barcode_scanner_library_v2.6.22.0-release.jar delete mode 100644 scan-engine/proguard-rules.pro delete mode 100644 scan-engine/src/androidTest/java/de/tillhub/scanengine/barcode/SunmiBarcodeScannerTest.kt delete mode 100644 scan-engine/src/androidTest/java/de/tillhub/scanengine/google/ui/GoogleScanningActivityTest.kt delete mode 100644 scan-engine/src/main/AndroidManifest.xml delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/ScanEngine.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/barcode/BarcodeScannerContainer.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/barcode/BarcodeScannerFactory.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/barcode/BarcodeScannerImpl.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/common/SingletonHolder.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/common/StandardExtensions.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/extensions/TabletExtensions.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/generic/GenericKeyEventScanner.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/google/DefaultCameraScanner.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/google/DefaultScannerActivityContract.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/google/ui/GoogleScanningActivity.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/google/ui/GoogleScanningViewModel.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/sunmi/barcode/SunmiBarcodeScanner.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/sunmi/camera/SunmiCameraScanner.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/sunmi/camera/SunmiScannerActivityContract.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/theme/Color.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/theme/ScanEngineTheme.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/theme/Style.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/zebra/ZebraBarcodeScanner.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/zebra/ZebraPairBarcodeActivity.kt delete mode 100644 scan-engine/src/main/java/de/tillhub/scanengine/zebra/ZebraPairBarcodeViewModel.kt delete mode 100644 scan-engine/src/main/res/drawable/classic_discoverable.png delete mode 100644 scan-engine/src/main/res/font/open_sans_semi_bold.ttf delete mode 100644 scan-engine/src/main/res/layout/activity_google_scanning.xml delete mode 100644 scan-engine/src/main/res/values-de/strings.xml delete mode 100644 scan-engine/src/main/res/values-w600dp/themes.xml delete mode 100644 scan-engine/src/main/res/values/colors.xml delete mode 100644 scan-engine/src/main/res/values/strings.xml delete mode 100644 scan-engine/src/main/res/values/styles.xml delete mode 100644 scan-engine/src/main/res/values/themes.xml delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/ScanEngineTest.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/barcode/BarcodeScannerContainerTest.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/common/InstantTaskExecutor.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelFunSpec.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelListener.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/data/ScannerManufacturerTest.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/generic/GenericKeyEventScannerTest.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/google/DefaultCameraScannerTest.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/google/ui/GoogleScanningViewModelTest.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/sunmi/SunmiCameraScannerTest.kt delete mode 100644 scan-engine/src/test/java/de/tillhub/scanengine/zebra/ZebraPairBarcodeViewModelTest.kt create mode 100644 shared/build.gradle.kts create mode 100644 shared/src/androidMain/AndroidManifest.xml create mode 100644 shared/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt create mode 100644 shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt create mode 100644 shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt create mode 100644 shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt rename {scan-engine/src/main/java => shared/src/commonMain/kotlin}/de/tillhub/scanengine/BarcodeScanner.kt (100%) rename {scan-engine/src/main/java => shared/src/commonMain/kotlin}/de/tillhub/scanengine/CameraScanner.kt (100%) rename {scan-engine/src/main/java => shared/src/commonMain/kotlin}/de/tillhub/scanengine/KeyEventScanner.kt (73%) create mode 100644 shared/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt create mode 100644 shared/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt create mode 100644 shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt create mode 100644 shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt create mode 100644 shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt create mode 100644 shared/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt rename {scan-engine/src/main/java => shared/src/commonMain/kotlin}/de/tillhub/scanengine/data/Scanner.kt (100%) rename {scan-engine/src/main/java => shared/src/commonMain/kotlin}/de/tillhub/scanengine/data/ScannerEvent.kt (100%) rename {scan-engine/src/main/java => shared/src/commonMain/kotlin}/de/tillhub/scanengine/data/ScannerResponse.kt (76%) rename {scan-engine/src/main/java => shared/src/commonMain/kotlin}/de/tillhub/scanengine/data/ScannerType.kt (73%) create mode 100644 shared/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt create mode 100644 shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt create mode 100644 shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt create mode 100644 shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 3011a93..0000000 --- a/.editorconfig +++ /dev/null @@ -1,54 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 4 -indent_style = space -insert_final_newline = true -tab_width = 4 -ij_continuation_indent_size = 4 -ij_formatter_off_tag = @formatter:off -ij_formatter_on_tag = @formatter:on -ij_formatter_tags_enabled = false -ij_smart_tabs = false -ij_wrap_on_typing = false -ij_visual_guides = 118 - -[*.{kt,kts}] -max_line_length = 118 -ij_kotlin_name_count_to_use_star_import = 999 -ij_kotlin_name_count_to_use_star_import_for_members = 999 -ij_kotlin_align_in_columns_case_branch = false -ij_kotlin_align_multiline_binary_operation = false -ij_kotlin_align_multiline_extends_list = false -ij_kotlin_align_multiline_method_parentheses = false -ij_kotlin_align_multiline_parameters = true -ij_kotlin_align_multiline_parameters_in_calls = false -ij_kotlin_assignment_wrap = normal -ij_kotlin_blank_lines_after_class_header = 0 -ij_kotlin_blank_lines_around_block_when_branches = 0 -ij_kotlin_block_comment_at_first_column = true -ij_kotlin_call_parameters_new_line_after_left_paren = true -ij_kotlin_call_parameters_right_paren_on_new_line = true -ij_kotlin_call_parameters_wrap = off -ij_kotlin_catch_on_new_line = false -ij_kotlin_class_annotation_wrap = split_into_lines -ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL -ij_kotlin_continuation_indent_for_chained_calls = false -ij_kotlin_continuation_indent_for_expression_bodies = false -ij_kotlin_continuation_indent_in_argument_lists = false -ij_kotlin_continuation_indent_in_elvis = false -ij_kotlin_continuation_indent_in_if_conditions = false -ij_kotlin_continuation_indent_in_parameter_lists = false -ij_kotlin_continuation_indent_in_supertype_lists = false -ij_kotlin_else_on_new_line = false -ij_kotlin_enum_constants_wrap = off -ij_kotlin_extends_list_wrap = normal -ij_kotlin_field_annotation_wrap = split_into_lines -ij_kotlin_finally_on_new_line = false -ij_kotlin_if_rparen_on_new_line = true -ij_kotlin_import_nested_classes = false -ij_kotlin_insert_whitespaces_in_simple_one_line_method = true -ij_kotlin_keep_blank_lines_before_right_brace = 2 -ij_kotlin_keep_blank_lines_in_code = 2 diff --git a/.gitignore b/.gitignore index 7a0e8fc..30f9a58 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,8 @@ out/ # Gradle files .gradle/ /build -/scan-engine/build -/buildSrc/build +/shared/build +/sample/build # Local configuration file (sdk path, etc) /local.properties @@ -59,3 +59,8 @@ out/ /.idea/navEditor.xml .externalNativeBuild .cxx +/.kotlin + +# CocoaPods +Pods/ +*.xcworkspace \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7f11450..b7a6267 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,10 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - alias(libs.plugins.androidApplication) apply false - alias(libs.plugins.kotlinAndroid) apply false - alias(libs.plugins.androidLibrary) apply false - alias(libs.plugins.detekt) apply false + //trick: for the same plugin versions in all sub-modules + alias(libs.plugins.android.library).apply(false) + alias(libs.plugins.kotlinMultiplatform).apply(false) + alias(libs.plugins.compose.compiler).apply(false) + alias(libs.plugins.compose).apply(false) + alias(libs.plugins.atomicfu).apply(false) + alias(libs.plugins.android.application).apply(false) + alias(libs.plugins.kotlin.android).apply(false) } diff --git a/gradle.properties b/gradle.properties index 9af2b38..7f53ad4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,29 +1,11 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app"s APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable configuration on demand -# https://docs.gradle.org/current/userguide/multi_project_configuration_and_execution.html#sec:configuration_on_demand -org.gradle.configureondemand=true +#Gradle +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.caching=true +org.gradle.configuration-cache=true -# Enable parallel execution -org.gradle.parallel=true +#Kotlin +kotlin.code.style=official -# Enable build caching -org.gradle.caching=true +#Android +android.useAndroidX=true +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec316b9..36836ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,159 +1,59 @@ [versions] -# Define the dependency versions -agp = "8.4.2" -kotlin = "1.9.22" -kotlinCoroutines = "1.7.3" -core = "1.13.1" -appcompat = "1.6.1" -constraintlayout = "2.1.4" -fragment = "1.6.2" -lifecycle = "2.7.0" -material = "1.11.0" -material3 = "1.2.0" -mlKit = "17.2.0" -playServicesMlkit = "18.3.0" -cameraX = "1.3.1" -cameraView = "1.3.1" +agp = "8.10.1" +kotlin = "2.1.21" +compose = "1.8.1" +atomicfu = "0.28.0" -activity-compose = "1.9.0" -compose-bom = "2024.06.00" +camera-core = "1.4.2" +camera-extensions = "1.4.2" +camera-lifecycle = "1.4.2" +camera-view = "1.4.2" -timber = "5.0.1" -androidDesugarJdkLibs = "2.0.4" -detekt = "1.23.6" - -junit = "4.13.2" -mockk = "1.13.8" -kotest = "5.8.0" - -kotestExtensionsAndroid = "0.1.1" -kotestRobolectric = "0.4.0" -androidxCoreTest = "1.5.0" -androidxRunner = "1.5.2" -androidxRules = "1.5.0" -androidxJunit = "1.1.5" -androidxTruth = "1.5.0" -espresso = "3.5.1" -robolectric = "4.11.1" +mlKit = "17.3.0" +playServicesMlkit = "18.3.1" +kotlinCoroutines = "1.10.2" +lifecycleRuntimeKtx = "2.9.1" +activityCompose = "1.10.1" [libraries] -# Define the libraries -kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinCoroutines" } -kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "core" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } -androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } -androidx-lifecycle-common = { group = "androidx.lifecycle", name = "lifecycle-common", version.ref = "lifecycle" } -androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycle" } -google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } +camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera-core" } +camera-core = { module = "androidx.camera:camera-core", version.ref = "camera-core" } +camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "camera-extensions" } +camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera-lifecycle" } +camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camera-view" } -activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } -compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } -compose-ui = { group = "androidx.compose.ui", name = "ui" } -compose-material3 = { group = "androidx.compose.material3", name = "material3",version.ref = "material3" } -compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -compose-ui-tooling = { group = "androidx.compose.ui" , name = "ui-tooling"} -compose-ui-tooling-preview = { group = "androidx.compose.ui" , name = "ui-tooling-preview"} +kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } +kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } +lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" } -androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" } -androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraView" } google-mlkit = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlKit" } google-play-services-mlkit = { group = "com.google.android.gms", name = "play-services-mlkit-barcode-scanning", version.ref = "playServicesMlkit" } -android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } -timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } - -# Unit testing -junit = { group = "junit", name = "junit", version.ref = "junit" } -mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } -robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } -kotest-robolectric = { group = "io.kotest.extensions", name = "kotest-extensions-robolectric", version.ref = "kotestRobolectric" } -kotest-extensions-android = { group = "br.com.colman", name = "kotest-extensions-android", version.ref = "kotestExtensionsAndroid" } -kotest-api = { group = "io.kotest", name = "kotest-framework-api", version.ref = "kotest" } -mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } -mockk-agent = { group = "io.mockk", name = "mockk-agent", version.ref = "mockk" } -mockk-agent-jvm = { group = "io.mockk", name = "mockk-agent-jvm", version.ref = "mockk" } -kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5-jvm", version.ref = "kotest" } -kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core-jvm", version.ref = "kotest" } -kotest-property = { group = "io.kotest", name = "kotest-property-jvm", version.ref = "kotest" } - -androidx-test-core = { group = "androidx.test", name = "core-ktx", version.ref = "androidxCoreTest" } -androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxRunner" } -androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxRules" } -androidx-test-junit = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" } -androidx-test-truth = { group = "androidx.test.ext", name = "truth", version.ref = "androidxTruth" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } -androidx-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "espresso" } -androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" } - -# Dependencies of the included build-logic -detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } -detekt-libraries = { group = "io.gitlab.arturbosch.detekt", name = "detekt-rules-libraries", version.ref = "detekt" } [plugins] -androidApplication = { id = "com.android.application", version.ref = "agp" } -kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -androidLibrary = { id = "com.android.library", version.ref = "agp" } -detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +android_library = { id = "com.android.library", version.ref = "agp" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose = { id = "org.jetbrains.compose", version.ref = "compose" } +atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } [bundles] core = [ "kotlin-coroutines" ] -compose = [ - "activity-compose", - "compose-bom", - "compose-ui", - "compose-material3", - "compose-ui-graphics", - "compose-ui-tooling", - "compose-ui-tooling-preview", -] -ui = [ - "androidx-core", - "androidx-fragment", - "androidx-appcompat", - "google-material", -] -lifecycle = [ - "androidx-lifecycle-common", - "androidx-lifecycle-runtime", +camera = [ + "camera-core", + "camera-camera2", + "camera-extensions", + "camera-lifecycle", + "camera-view" ] mlkit = [ "google-mlkit", "google-play-services-mlkit", -] -camera = [ - "androidx-camera-camera2", - "androidx-camera-lifecycle", - "androidx-camera-view" -] -testing = [ - "junit", - "mockk", - "mockk-agent-jvm", - "kotest-runner", - "kotest-assertions", - "kotest-property", - "kotlin-coroutines-test" -] -robolectric = [ - "robolectric", - "kotest-robolectric", - "androidx-test-core", - "kotest-extensions-android" -] -testing-android = [ - "androidx-test-core", - "androidx-test-runner", - "androidx-test-rules", - "androidx-test-junit", - "androidx-test-truth", - "androidx-espresso-core", - "androidx-espresso-contrib", - "androidx-espresso-intents", - "kotlin-coroutines-test" -] +] \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0d408fe..c854a28 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Feb 11 09:37:55 CET 2022 +#Thu Jun 12 08:47:36 CEST 2025 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/jitpack.yml b/jitpack.yml deleted file mode 100644 index f29a667..0000000 --- a/jitpack.yml +++ /dev/null @@ -1,5 +0,0 @@ -jdk: - - openjdk17 -before_install: - - sdk install java 17.0.1-open - - sdk use java 17.0.1-open \ No newline at end of file diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index ce9df40..1bcf3e5 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -1,49 +1,57 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { - alias(libs.plugins.androidApplication) - alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) } android { namespace = "de.tillhub.scanengine.sample" - - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "de.tillhub.scanengine.sample" minSdk = 24 - targetSdk = 34 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" - vectorDrawables { - useSupportLibrary = true - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - tasks.withType { - useJUnitPlatform() - } - - tasks.withType { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } + kotlinOptions { + jvmTarget = JvmTarget.JVM_17.target } buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.9" - } } dependencies { - - implementation(project(":scan-engine")) - - implementation(libs.androidx.core) - implementation(libs.bundles.compose) - implementation(libs.bundles.lifecycle) -} + implementation(projects.shared) + + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(compose.ui) + implementation(compose.uiTooling) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.activity.compose) +} \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 1a7c5ff..684d670 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -4,20 +4,18 @@ + android:theme="@style/AppTheme"> + android:exported="true"> - - + \ No newline at end of file diff --git a/sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt b/sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt index 7a2b2cd..a939278 100644 --- a/sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt +++ b/sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt @@ -1,326 +1,46 @@ package de.tillhub.scanengine.sample -import android.annotation.SuppressLint import android.os.Bundle -import android.view.KeyEvent -import android.widget.Toast +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableIntState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -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.material3.Scaffold import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope -import de.tillhub.scanengine.CameraScanner -import de.tillhub.scanengine.KeyEventScanner import de.tillhub.scanengine.ScanEngine -import de.tillhub.scanengine.data.Scanner +import de.tillhub.scanengine.camera.ui.CameraScreen import de.tillhub.scanengine.data.ScannerEvent -import de.tillhub.scanengine.data.ScannerResponse -import de.tillhub.scanengine.data.ScannerType -import de.tillhub.scanengine.sample.ui.theme.TillhubScanEngineTheme -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import de.tillhub.scanengine.sample.ui.theme.Tillhub_Scan_EngineTheme import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { - private val scanEngine by lazy { - ScanEngine.getInstance(applicationContext) + ScanEngine.getInstance() } - private lateinit var cameraScanner: CameraScanner - private lateinit var keyEventScanner: KeyEventScanner - private var scanCode = mutableStateOf("") - private val scannerList = mutableStateListOf() - private val showProgress = mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - cameraScanner = scanEngine.newCameraScanner(this) - keyEventScanner = scanEngine.newKeyEventScanner(this) - lifecycleScope.launch { - scanEngine.barcodeScanner.observeScanners().collect { scanners -> - showProgress.value = false - scannerList.clear() - scannerList.addAll(scanners) - } - } + enableEdgeToEdge() setContent { - TillhubScanEngineTheme { - Content() - } - } - scanEngine.barcodeScanner.scanWithKey("key") - - lifecycleScope.launch { - scanEngine.observeScannerResults().collect { - when (it) { - is ScannerEvent.External.Connecting -> { - showProgress.value = true - } - - is ScannerEvent.ScanResult -> { - scanCode.value = (it as? ScannerEvent.ScanResult)?.value.orEmpty() - } - - else -> Unit - } - - } - } - } - - @SuppressLint("RestrictedApi") - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - keyEventScanner.dispatchKeyEvent(event) - return super.dispatchKeyEvent(event) - } - - @Composable - private fun Content() { - var autoReconnect by remember { mutableStateOf(false) } - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = scanCode.value) - Spacer(modifier = Modifier.height(36.dp)) - Button( - onClick = { - cameraScanner.startCameraScanner("key") - } - ) { - Text(text = "Start camera scanner") - } - Spacer(modifier = Modifier.height(36.dp)) - Button( - onClick = { - scanEngine.barcodeScanner.startPairingScreen(ScannerType.ZEBRA) - } - ) { - Text(text = "Start zebra scanner") - } - Spacer(modifier = Modifier.height(36.dp)) - Button( - onClick = { - scannerList.firstOrNull { it.isConnected }?.apply { - lifecycleScope.launch { - scanEngine.barcodeScanner.disconnect(id) - } - } - } - ) { - Text(text = "Disconnect scanner") - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "Auto-Reconnect: ") - Switch( - checked = autoReconnect, - onCheckedChange = { - Toast - .makeText( - this@MainActivity, - "Not implemented yet", - Toast.LENGTH_SHORT - ) - .show() - autoReconnect = it - } + Tillhub_Scan_EngineTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + scanEngine.getCameraView( + scanKey = "camera_scan_key", + modifier = Modifier.padding(innerPadding) ) } - Spacer(modifier = Modifier.height(16.dp)) - ShowScannerList(scannerList, autoReconnect) - if (showProgress.value) { - CircularProgressIndicator( - modifier = Modifier.width(64.dp), - color = MaterialTheme.colorScheme.secondary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - } - } - } - } - - @Composable - private fun ShowScannerList(scanners: List, autoReconnect: Boolean) { - val coroutineScope = rememberCoroutineScope() - val activeScanners = scanners.filter { it.isConnected } - val inactiveScanners = scanners.filter { !it.isConnected } - val errorDrawable = remember { mutableIntStateOf(0) } - val selectedScannerId = remember { mutableStateOf("") } - errorDrawable.intValue.takeIf { it != 0 }?.let { drawable -> - Image( - painter = painterResource(id = drawable), - contentDescription = "Error", - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - ) - } - LazyColumn { - if (activeScanners.isNotEmpty()) { - errorDrawable.intValue = 0 - item { - Text( - "Active Scanners", style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primary) - .padding(8.dp) - ) - } - items(activeScanners) { activeScanner -> - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - lifecycleScope.launch { - scanEngine.barcodeScanner.disconnect(activeScanner.id) - } - } - .padding(8.dp) - ) { - Text( - activeScanner.name, - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold - ) - Text( - activeScanner.serialNumber, - modifier = Modifier.padding(bottom = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - } - - } - } - if (inactiveScanners.isNotEmpty()) { - item { - Text( - text = "Inactive Scanners", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primary) - .padding(8.dp) - ) - } - items(inactiveScanners) { inactiveScanner -> - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - if (!autoReconnect) { - selectedScannerId.value = inactiveScanner.id - connectScanner(coroutineScope, inactiveScanner.id, errorDrawable) - } else { - Toast - .makeText( - this@MainActivity, - getString(R.string.auto_reconnect_turnoff), Toast.LENGTH_SHORT - ) - .show() - } - } - .padding(8.dp) - ) { - Text( - inactiveScanner.name, - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold - ) - Text( - inactiveScanner.serialNumber, - modifier = Modifier.padding(bottom = 8.dp), - style = MaterialTheme.typography.bodyLarge - ) - } - } } } - } - private fun connectScanner( - coroutineScope: CoroutineScope, - inactiveScannerId: String, - errorDrawable: MutableIntState - ) { - coroutineScope.launch { - when (val res = scanEngine.barcodeScanner.connect(inactiveScannerId)) { - is ScannerResponse.Error.Connect -> { - withContext(Dispatchers.Main) { - errorDrawable.intValue = res.barcode - } + lifecycleScope.launch { + scanEngine.observeScannerResults().collect { + (it as? ScannerEvent.ScanResult)?.let { result -> + Log.d("SCANNER", result.scanKey + " " + result.value) } - - ScannerResponse.Error.NotFound -> Toast.makeText( - this@MainActivity, - getString(R.string.scanner_not_found), - Toast.LENGTH_SHORT - ).show() - - ScannerResponse.Error.Disconnect -> Toast.makeText( - this@MainActivity, - getString(R.string.scanner_disconnection_error), - Toast.LENGTH_SHORT - ).show() - - ScannerResponse.Success.Connect -> Toast.makeText( - this@MainActivity, - getString(R.string.scanner_connected), Toast.LENGTH_SHORT - ) - .show() - - ScannerResponse.Success.Disconnect -> Toast.makeText( - this@MainActivity, - getString(R.string.scanner_disconnected), Toast.LENGTH_SHORT - ) - .show() } } } -} - +} \ No newline at end of file diff --git a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Theme.kt b/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Theme.kt index 0fcf762..1fa9b54 100644 --- a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Theme.kt +++ b/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Theme.kt @@ -9,11 +9,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( primary = Purple80, @@ -25,10 +21,20 @@ private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ ) @Composable -fun TillhubScanEngineTheme( +fun Tillhub_Scan_EngineTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, @@ -43,18 +49,10 @@ fun TillhubScanEngineTheme( darkTheme -> DarkColorScheme else -> LightColorScheme } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) -} +} \ No newline at end of file diff --git a/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 0bf10d5..0000000 --- a/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml index 476df3b..07d5da9 100644 --- a/sample/src/main/res/drawable/ic_launcher_background.xml +++ b/sample/src/main/res/drawable/ic_launcher_background.xml @@ -5,6 +5,166 @@ android:viewportWidth="108" android:viewportHeight="108"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/drawable/ic_launcher_foreground.xml b/sample/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index eca70cf..6f3b755 100644 --- a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index eca70cf..6f3b755 100644 --- a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.png b/sample/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 6d3e0d6cca0adf6b21adc937eb0e5ba26316120a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 461 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBxg<{)$B+ufw|5qLu>?vuJgh&`F3_XZ& zSKv2k5~ z-u`ug#oZv6G#=a6yv_XD@8fpeflj}-3X4we5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher.png b/sample/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 65aa8f69cd16323392f3bfe4ddff867ae3d8fce6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 368 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?3oVGw3ym^DWND9BhG z00S@Ug#ZTM7py{Nu3NKi%8Opy5V7vaSHJ#B!}(Lw7O%^k{x$7&|K00W z-i=))w{$NS?2CzKYOV3RYhQN6tKmL_6xV`<5MowF%g%VigKKWfud0f9pAjnF(ZE@D z`DV;N#_1DyUxozjop0d%Lh`u1U`Ipc&x;F|zml{T)@JB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index de40cc86823c274608e1c7da1a4223a0cf4b2fd7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 482 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBxpGez$B+ufw|5P_m>nhDKh_^{&e#$xsB^_> z!=>(ssBF!SrDjPlHEkMmZ#i_8a4zBXlr^h$`n7~}b7e~9{<7}Y+Be5VjD23e`IRV` z$fq=gF@dc?k|B*@25!cUL@ujb`Q>?=S9yh=b>3T_YBO`$lq5#KTkbFa{9l@PH_qPw z{QB(qwe|a+|9fXvYQ4YGN3%}>kAXO)!b;L*at&ls@0(~HxPCtWTdy()+-28RgkCs) z+k2g!e$aN`=fc}Au3%W*zrUMVO4YK7upcn3j5vMTCh{Mn0rLSK24jXZ4EPz-ZVAX< U^i`a(0vII>p00i_>zopr08~Vn!vFvP diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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 diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..28d4b77f9f036a47549d47db79c16788749dca10 GIT binary patch literal 2884 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeN``W 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/sample/src/main/res/values/colors.xml b/sample/src/main/res/values/colors.xml deleted file mode 100644 index 3ea04e7..0000000 --- a/sample/src/main/res/values/colors.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml deleted file mode 100644 index ba91b49..0000000 --- a/sample/src/main/res/values/strings.xml +++ /dev/null @@ -1,8 +0,0 @@ - - sample - Scanner Not found - Scanner connected - Scanner disconnected - Turn off auto reconnect - Error disconnecting scanner - diff --git a/sample/src/main/res/values/styles.xml b/sample/src/main/res/values/styles.xml new file mode 100644 index 0000000..6b1f222 --- /dev/null +++ b/sample/src/main/res/values/styles.xml @@ -0,0 +1,4 @@ + + + - diff --git a/scan-engine/src/main/res/values/colors.xml b/scan-engine/src/main/res/values/colors.xml deleted file mode 100644 index 3aed6ab..0000000 --- a/scan-engine/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #80000000 - diff --git a/scan-engine/src/main/res/values/strings.xml b/scan-engine/src/main/res/values/strings.xml deleted file mode 100644 index b6f1990..0000000 --- a/scan-engine/src/main/res/values/strings.xml +++ /dev/null @@ -1,15 +0,0 @@ - - Bluetooth error! - Device does not reach minimum requirement. - - Grant permissions - In order to use barcode reader you need to grant access by accepting the next permission dialog.\n\nWould you like to continue? - Request camera permission - - OK - Loading - Barcode pairing - Barcode pairing successful - Please first scan the barcode and then proceed to the Bluetooth pairing screen. - "Bluetooth Settings" - diff --git a/scan-engine/src/main/res/values/styles.xml b/scan-engine/src/main/res/values/styles.xml deleted file mode 100644 index 14ed6fe..0000000 --- a/scan-engine/src/main/res/values/styles.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/scan-engine/src/main/res/values/themes.xml b/scan-engine/src/main/res/values/themes.xml deleted file mode 100644 index 867d19b..0000000 --- a/scan-engine/src/main/res/values/themes.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/ScanEngineTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/ScanEngineTest.kt deleted file mode 100644 index d6859ee..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/ScanEngineTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package de.tillhub.scanengine - -import android.app.Activity -import android.bluetooth.BluetoothManager -import android.content.Context -import androidx.activity.result.ActivityResultCaller -import de.tillhub.scanengine.barcode.BarcodeScannerContainer -import de.tillhub.scanengine.data.ScannerType -import de.tillhub.scanengine.generic.GenericKeyEventScanner -import de.tillhub.scanengine.google.DefaultCameraScanner -import de.tillhub.scanengine.sunmi.camera.SunmiCameraScanner -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import kotlinx.coroutines.ExperimentalCoroutinesApi - -@ExperimentalCoroutinesApi -class ScanEngineTest : FunSpec({ - - lateinit var context: Context - lateinit var activity: Activity - lateinit var resultCaller: ActivityResultCaller - lateinit var scanEngine: ScanEngine - - beforeTest { - mockkObject(ScannerType) - context = mockk(relaxed = true) { - every { - getSystemService(Context.BLUETOOTH_SERVICE) - } returns mockk(relaxed = true) - } - activity = mockk(relaxed = true) - resultCaller = mockk(relaxed = true) - scanEngine = ScanEngine.getInstance(context).initBarcodeScanners(ScannerType.ZEBRA) - } - - test("newCameraScanner should return SunmiCameraScanner for SUNMI scanner type") { - every { ScannerType.get() } returns ScannerType.SUNMI - - val scanner = scanEngine.newCameraScanner(resultCaller) - scanner.shouldBeInstanceOf() - } - - test("newCameraScanner should return DefaultCameraScanner for other scanner types") { - every { ScannerType.get() } returns ScannerType.UNKNOWN - - val scanner = scanEngine.newCameraScanner(resultCaller) - scanner.shouldBeInstanceOf() - } - - test("barcodeScanner lazy initialization should initialize BarcodeScannerContainer correctly") { - val barcodeScanner = scanEngine.barcodeScanner - barcodeScanner.shouldBeInstanceOf() - } - - test("newKeyEventScanner") { - val scanner = scanEngine.newKeyEventScanner(activity) - scanner.shouldBeInstanceOf() - } -}) diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/barcode/BarcodeScannerContainerTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/barcode/BarcodeScannerContainerTest.kt deleted file mode 100644 index eff95bc..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/barcode/BarcodeScannerContainerTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package de.tillhub.scanengine.barcode - -import android.app.Activity -import android.bluetooth.BluetoothManager -import android.content.Context -import de.tillhub.scanengine.data.ScannerEvent -import de.tillhub.scanengine.data.ScannerType -import de.tillhub.scanengine.generic.GenericKeyEventScanner -import de.tillhub.scanengine.sunmi.barcode.SunmiBarcodeScanner -import de.tillhub.scanengine.zebra.ZebraBarcodeScanner -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher - -@ExperimentalCoroutinesApi -class BarcodeScannerContainerTest : FunSpec({ - - lateinit var context: Context - lateinit var activity: Activity - lateinit var mutableScannerEvents: MutableStateFlow - lateinit var testScope: TestScope - lateinit var scannerFactory: BarcodeScannerFactory - - beforeTest { - mockkObject(ScannerType) - testScope = TestScope(UnconfinedTestDispatcher()) - context = mockk(relaxed = true) { - every { - getSystemService(Context.BLUETOOTH_SERVICE) - } returns mockk(relaxed = true) - } - activity = mockk(relaxed = true) - mutableScannerEvents = MutableStateFlow(mockk(relaxed = true)) - scannerFactory = mockk() - } - - test("getScannersByType success") { - val container = BarcodeScannerContainer(context, mutableScannerEvents) - container.addScanner(ScannerType.ZEBRA) - val zebraScanner = container.getScannersByType(ZebraBarcodeScanner::class.java) - zebraScanner.shouldBeInstanceOf() - } - test("addScanner") { - val container = BarcodeScannerContainer(context, mutableScannerEvents) - val scanner = GenericKeyEventScanner(activity, mutableScannerEvents) - container.addScanner(scanner) - val genericScanner = container.getScannersByType(GenericKeyEventScanner::class.java) - genericScanner.shouldBeInstanceOf() - } - test("getScannersByType error") { - val type = ZebraBarcodeScanner::class.java - val container = BarcodeScannerContainer(context, mutableScannerEvents) - val exception = shouldThrow { - container.getScannersByType(type) - } - exception.message shouldBe "No scanner found of type $type" - } - - test("observeScannerResults") { - every { ScannerType.get() } returns ScannerType.SUNMI - val zebraScanner: ZebraBarcodeScanner = mockk(relaxed = true) { - every { observeScannerResults() } returns mutableScannerEvents - } - - val sunmiScanner: SunmiBarcodeScanner = mockk(relaxed = true) { - every { observeScannerResults() } returns mutableScannerEvents - } - every { scannerFactory.getZebraBarcodeScanner(any(), any()) } returns zebraScanner - every { scannerFactory.getSunmiBarcodeScanner(any(), any()) } returns sunmiScanner - - val container = BarcodeScannerContainer( - context = context, - mutableScannerEvents = mutableScannerEvents, - scannerFactory = scannerFactory - ) - container.addScanner(ScannerType.ZEBRA) - val testResults = mutableListOf() - val event = ScannerEvent.ScanResult("value") - mutableScannerEvents.tryEmit(event) - testScope.launch { - container.observeScannerResults().toList(testResults) - } - testResults shouldBe listOf(event, event) - verify { - zebraScanner.observeScannerResults() - sunmiScanner.observeScannerResults() - } - } - - test("scanWithKey") { - val zebraScanner = mockk(relaxed = true) - every { scannerFactory.getZebraBarcodeScanner(any(), any()) } returns zebraScanner - - val container = BarcodeScannerContainer( - context, - mutableScannerEvents, - scannerFactory = scannerFactory - ) - container.addScanner(ScannerType.ZEBRA) - container.scanWithKey("test_key") - verify { - zebraScanner.scanWithKey("test_key") - } - } - - test("startPairingScreen") { - val zebraScanner = mockk(relaxed = true) - every { scannerFactory.getZebraBarcodeScanner(any(), any()) } returns zebraScanner - - val container = BarcodeScannerContainer( - context, - mutableScannerEvents, - scannerFactory = scannerFactory - ) - container.addScanner(ScannerType.ZEBRA) - container.startPairingScreen(ScannerType.ZEBRA) - verify { zebraScanner.startPairingScreen(ScannerType.ZEBRA) } - } -}) diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/common/InstantTaskExecutor.kt b/scan-engine/src/test/java/de/tillhub/scanengine/common/InstantTaskExecutor.kt deleted file mode 100644 index 0d83592..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/common/InstantTaskExecutor.kt +++ /dev/null @@ -1,33 +0,0 @@ -package de.tillhub.scanengine.common - -import android.annotation.SuppressLint -import androidx.arch.core.executor.ArchTaskExecutor -import androidx.arch.core.executor.TaskExecutor - -@SuppressLint("RestrictedApi") -class InstantTaskExecutor { - - private val taskExecutor: ArchTaskExecutor by lazy { - ArchTaskExecutor.getInstance() - } - - fun setupLiveData() { - taskExecutor.setDelegate(object : TaskExecutor() { - override fun executeOnDiskIO(runnable: Runnable) { - runnable.run() - } - - override fun postToMainThread(runnable: Runnable) { - runnable.run() - } - - override fun isMainThread(): Boolean { - return true - } - }) - } - - fun resetLiveData() { - taskExecutor.setDelegate(null) - } -} diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelFunSpec.kt b/scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelFunSpec.kt deleted file mode 100644 index 6d4736a..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelFunSpec.kt +++ /dev/null @@ -1,15 +0,0 @@ -package de.tillhub.scanengine.common - -import io.kotest.core.listeners.TestListener -import io.kotest.core.spec.style.FunSpec -import kotlinx.coroutines.ExperimentalCoroutinesApi - -@ExperimentalCoroutinesApi -abstract class ViewModelFunSpec( - body: FunSpec.() -> Unit = {} -) : FunSpec(body) { - - override fun listeners(): List { - return listOf(ViewModelListener()) - } -} diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelListener.kt b/scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelListener.kt deleted file mode 100644 index 03d8c9c..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/common/ViewModelListener.kt +++ /dev/null @@ -1,27 +0,0 @@ -package de.tillhub.scanengine.common - -import io.kotest.core.listeners.TestListener -import io.kotest.core.spec.Spec -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain - -@ExperimentalCoroutinesApi -class ViewModelListener( - private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), - private val instantTaskExecutor: InstantTaskExecutor = InstantTaskExecutor() -) : TestListener { - - override suspend fun beforeSpec(spec: Spec) { - Dispatchers.setMain(testDispatcher) - instantTaskExecutor.setupLiveData() - } - - override suspend fun afterSpec(spec: Spec) { - Dispatchers.resetMain() - instantTaskExecutor.resetLiveData() - } -} diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/data/ScannerManufacturerTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/data/ScannerManufacturerTest.kt deleted file mode 100644 index 786f19d..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/data/ScannerManufacturerTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package de.tillhub.scanengine.data - -import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import org.robolectric.shadows.ShadowBuild - -@RobolectricTest -internal class ScannerManufacturerTest : FunSpec({ - - test("SUNMI device") { - ShadowBuild.setManufacturer("SUNMI") - ScannerType.get() shouldBe ScannerType.SUNMI - } - - test("UNKNOWN device") { - ShadowBuild.setManufacturer("UNKNOWN") - ScannerType.get() shouldBe ScannerType.UNKNOWN - } -}) diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/generic/GenericKeyEventScannerTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/generic/GenericKeyEventScannerTest.kt deleted file mode 100644 index 11af24b..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/generic/GenericKeyEventScannerTest.kt +++ /dev/null @@ -1,216 +0,0 @@ -package de.tillhub.scanengine.generic - -import android.app.Activity -import android.view.KeyEvent -import android.view.View -import android.widget.EditText -import de.tillhub.scanengine.data.ScannerEvent -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.every -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest - -@OptIn(ExperimentalCoroutinesApi::class) -class GenericKeyEventScannerTest : FunSpec({ - - lateinit var activity: Activity - lateinit var mockEditText: EditText - lateinit var mutableScannerEvents: MutableStateFlow - lateinit var scanner: GenericKeyEventScanner - lateinit var testScope: TestScope - - beforeTest { - activity = mockk(relaxed = true) - mockEditText = mockk(relaxed = true) - testScope = TestScope(UnconfinedTestDispatcher()) - mutableScannerEvents = spyk(MutableStateFlow(ScannerEvent.External.NotConnected)) - - scanner = GenericKeyEventScanner(activity, mutableScannerEvents) - } - - test("dispatchKeyEvent should append input and emit ScanResult when Enter is pressed") { - val keyEventA = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 'A'.code - every { eventTime } returns 100L - every { keyCode } returns KeyEvent.KEYCODE_A - } - - val keyEventEnter = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 1 - every { eventTime } returns 101L - every { keyCode } returns KeyEvent.KEYCODE_ENTER - } - - every { activity.findViewById(android.R.id.content).findFocus() } returns mockEditText - every { mockEditText.hasFocus() } returns false - - scanner.dispatchKeyEvent(keyEventA, "ScanKey") - - scanner.dispatchKeyEvent(keyEventEnter, "ScanKey") - - val testResults = mutableListOf() - testScope.launch { - scanner.observeScannerResults().toList(testResults) - } - - verify(exactly = 1) { mutableScannerEvents.tryEmit(any()) } - - val result = testResults.first() as ScannerEvent.ScanResult - result.value shouldBe "A" - result.scanKey shouldBe "ScanKey" - } - - test("dispatchKeyEvent should not append non-printable keys and emit ScanResult when Enter is pressed") { - val keyEventA = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 'A'.code - every { eventTime } returns 100L - every { keyCode } returns KeyEvent.KEYCODE_A - } - - val keyEventShiftLef = mockk { - every { action } returns KeyEvent.KEYCODE_SHIFT_LEFT - every { unicodeChar } returns 0 - every { eventTime } returns 101L - every { keyCode } returns KeyEvent.KEYCODE_SHIFT_LEFT - } - - val keyEventEnter = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 1 - every { eventTime } returns 101L - every { keyCode } returns KeyEvent.KEYCODE_ENTER - } - - every { activity.findViewById(android.R.id.content).findFocus() } returns mockEditText - every { mockEditText.hasFocus() } returns false - - scanner.dispatchKeyEvent(keyEventA, "ScanKey") - - scanner.dispatchKeyEvent(keyEventShiftLef, "ScanKey") - - scanner.dispatchKeyEvent(keyEventEnter, "ScanKey") - - val testResults = mutableListOf() - testScope.launch { - scanner.observeScannerResults().toList(testResults) - } - - verify(exactly = 1) { mutableScannerEvents.tryEmit(any()) } - - val result = testResults.first() as ScannerEvent.ScanResult - result.value shouldBe "A" - result.scanKey shouldBe "ScanKey" - } - - test("dispatchKeyEvent should not handle key events on invalid action") { - val keyEventA = mockk { - every { action } returns KeyEvent.ACTION_UP - } - scanner.dispatchKeyEvent(keyEventA, "ScanKey") - - val testResults = mutableListOf() - testScope.launch { - scanner.observeScannerResults().toList(testResults) - } - verify(exactly = 0) { mutableScannerEvents.tryEmit(any()) } - - testResults.first().shouldBeInstanceOf() - } - - test("dispatchKeyEvent should not handle key events on invalid keycode") { - val keyEventA = mockk { - every { action } returns KeyEvent.ACTION_UP - every { keyCode } returns KeyEvent.KEYCODE_SHIFT_LEFT - } - scanner.dispatchKeyEvent(keyEventA, "ScanKey") - - val testResults = mutableListOf() - testScope.launch { - scanner.observeScannerResults().toList(testResults) - } - verify(exactly = 0) { mutableScannerEvents.tryEmit(any()) } - - testResults.first().shouldBeInstanceOf() - } - - test("dispatchKeyEvent should not handle key events on invalid unicodeChar") { - val keyEventA = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 0 - every { eventTime } returns 100L - every { keyCode } returns KeyEvent.KEYCODE_A - } - scanner.dispatchKeyEvent(keyEventA, "ScanKey") - - val testResults = mutableListOf() - testScope.launch { - scanner.observeScannerResults().toList(testResults) - } - verify(exactly = 0) { mutableScannerEvents.tryEmit(any()) } - - testResults.first().shouldBeInstanceOf() - } - - test("dispatchKeyEvent should not handle key events when exceed scan threshold") { - val testResults = mutableListOf() - val keyEventA = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 'A'.code - every { eventTime } returns 100L - every { keyCode } returns KeyEvent.KEYCODE_A - } - - val keyEventEnter = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 0 - every { eventTime } returns 151L - every { keyCode } returns KeyEvent.KEYCODE_ENTER - } - - every { activity.findViewById(android.R.id.content).findFocus() } returns mockEditText - every { mockEditText.hasFocus() } returns false - - scanner.dispatchKeyEvent(keyEventA, "ScanKey") - - scanner.dispatchKeyEvent(keyEventEnter, "ScanKey") - - testScope.launch { - scanner.observeScannerResults().toList(testResults) - } - - verify(exactly = 0) { mutableScannerEvents.tryEmit(any()) } - - testResults.first().shouldBeInstanceOf() - } - - test("dispatchKeyEvent should not handle key events when EditText has focus") { - runTest { - val keyEventA = mockk { - every { action } returns KeyEvent.ACTION_DOWN - every { unicodeChar } returns 'A'.code - every { eventTime } returns 100L - every { keyCode } returns KeyEvent.KEYCODE_A - } - - every { activity.findViewById(android.R.id.content).findFocus() } returns mockEditText - every { mockEditText.hasFocus() } returns true - - scanner.dispatchKeyEvent(keyEventA, "ScanKey") - - verify(exactly = 0) { mutableScannerEvents.tryEmit(any()) } - } - } -}) diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/google/DefaultCameraScannerTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/google/DefaultCameraScannerTest.kt deleted file mode 100644 index 016ea87..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/google/DefaultCameraScannerTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package de.tillhub.scanengine.google - -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.ActivityResultCaller -import androidx.activity.result.ActivityResultLauncher -import de.tillhub.scanengine.CameraScanner -import de.tillhub.scanengine.data.ScannerEvent -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher - -@ExperimentalCoroutinesApi -class DefaultCameraScannerTest : FunSpec({ - - lateinit var resultCaller: ActivityResultCaller - lateinit var mutableScannerEvents: MutableStateFlow - lateinit var scannerLauncher: ActivityResultLauncher - lateinit var defaultCameraScanner: CameraScanner - lateinit var testScope: TestScope - - beforeTest { - val callbackSlot = slot>() - - mutableScannerEvents = MutableStateFlow(ScannerEvent.External.NotConnected) - scannerLauncher = mockk>(relaxed = true) - resultCaller = mockk(relaxed = true) { - every { - registerForActivityResult( - any(), - capture(callbackSlot) - ) - } returns scannerLauncher - } - testScope = TestScope(UnconfinedTestDispatcher()) - defaultCameraScanner = DefaultCameraScanner(resultCaller, mutableScannerEvents) - } - - test("startCameraScanner should emit InProgress event and launch scanner") { - defaultCameraScanner.startCameraScanner("testScanKey") - - mutableScannerEvents.value.shouldBeInstanceOf() - (mutableScannerEvents.value as ScannerEvent.Camera.InProgress).scanKey shouldBe "testScanKey" - verify { scannerLauncher.launch(Unit) } - } - - test("registerForActivityResult callback emits ScanEvent_Canceled") { - val callbackSlot = slot>() - every { - resultCaller.registerForActivityResult(any(), capture(callbackSlot)) - } returns scannerLauncher - - defaultCameraScanner = DefaultCameraScanner(resultCaller, mutableScannerEvents) - - val events = ScannerEvent.Camera.Canceled - callbackSlot.captured.onActivityResult(events) - - val testResults = mutableListOf() - testScope.launch { - defaultCameraScanner.observeScannerResults().toList(testResults) - } - defaultCameraScanner.startCameraScanner() - testResults.first() shouldBe ScannerEvent.Camera.Canceled - } - - test("scannerLauncher should emit Success event") { - val callbackSlot = slot>() - every { - resultCaller.registerForActivityResult(any(), capture(callbackSlot)) - } returns scannerLauncher - - defaultCameraScanner = DefaultCameraScanner(resultCaller, mutableScannerEvents) - - val events = ScannerEvent.ScanResult("123456789", "key") - callbackSlot.captured.onActivityResult(events) - - val testResults = mutableListOf() - testScope.launch { - defaultCameraScanner.observeScannerResults().toList(testResults) - } - defaultCameraScanner.startCameraScanner("key") - val result = testResults.first() - result.shouldBeInstanceOf() - result.value shouldBe "123456789" - } -}) diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/google/ui/GoogleScanningViewModelTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/google/ui/GoogleScanningViewModelTest.kt deleted file mode 100644 index 005253c..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/google/ui/GoogleScanningViewModelTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package de.tillhub.scanengine.google.ui - -import androidx.camera.core.ImageInfo -import androidx.camera.core.ImageProxy -import com.google.android.gms.tasks.OnCompleteListener -import com.google.android.gms.tasks.OnSuccessListener -import com.google.android.gms.tasks.Task -import com.google.mlkit.vision.barcode.BarcodeScanner -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.mockk.Ordering -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue - -@ExperimentalCoroutinesApi -class GoogleScanningViewModelTest : FunSpec({ - - lateinit var scanner: BarcodeScanner - lateinit var imageProxy: ImageProxy - lateinit var inputImage: InputImage - lateinit var inputImageGenerator: InputImageGenerator - lateinit var imageInfo: ImageInfo - lateinit var viewModel: GoogleScanningViewModel - lateinit var task: Task> - lateinit var testScope: TestScope - - beforeTest { - testScope = TestScope(UnconfinedTestDispatcher()) - task = mockk() - inputImage = mockk() - imageInfo = mockk { - every { rotationDegrees } returns 1 - } - imageProxy = mockk { - every { image } returns mockk() - every { getImageInfo() } returns imageInfo - every { close() } just Runs - } - inputImageGenerator = mockk { - every { fromMediaImage(any(), any()) } returns inputImage - } - scanner = mockk { - every { process(inputImage) } returns task - every { close() } just Runs - } - viewModel = GoogleScanningViewModel(scanner, inputImageGenerator) - } - - test("barcode scan updates state to CodeScanned") { - val barcode = mockk { - every { rawValue } returns "1234567890" - } - val analyzer = viewModel.analyzer as QRImageAnalyzer - val testResults = mutableListOf() - - every { task.addOnSuccessListener(any()) } answers { - val arr = firstArg>>() - arr.onSuccess(listOf(barcode)) - task - } - every { task.addOnCompleteListener(any()) } answers { - val arr = firstArg>>() - arr.onComplete(task) - task - } - viewModel.scanningState.value shouldBe ScanningState.Idle - - testScope.launch { - viewModel.scanningState.toList(testResults) - } - analyzer.analyze(imageProxy) - val result = testResults.last() - assertTrue(result is ScanningState.CodeScanned) - assertEquals((result as ScanningState.CodeScanned).barcode, "1234567890") - - verify(ordering = Ordering.ORDERED) { - scanner.close() - imageProxy.close() - } - } -}) diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/sunmi/SunmiCameraScannerTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/sunmi/SunmiCameraScannerTest.kt deleted file mode 100644 index 80d429a..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/sunmi/SunmiCameraScannerTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.ActivityResultCaller -import androidx.activity.result.ActivityResultLauncher -import de.tillhub.scanengine.CameraScanner -import de.tillhub.scanengine.data.ScannerEvent -import de.tillhub.scanengine.sunmi.camera.SunmiCameraScanner -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher - -@ExperimentalCoroutinesApi -class SunmiCameraScannerTest : FunSpec({ - - lateinit var resultCaller: ActivityResultCaller - lateinit var mutableScannerEvents: MutableStateFlow - lateinit var scannerLauncher: ActivityResultLauncher - lateinit var sunmiCameraScanner: CameraScanner - lateinit var testScope: TestScope - - beforeTest { - val callbackSlot = slot>>() - - mutableScannerEvents = MutableStateFlow(ScannerEvent.External.NotConnected) - scannerLauncher = mockk>(relaxed = true) - resultCaller = mockk(relaxed = true) { - every { - registerForActivityResult>( - any(), - capture(callbackSlot) - ) - } returns scannerLauncher - } - testScope = TestScope(UnconfinedTestDispatcher()) - sunmiCameraScanner = SunmiCameraScanner(resultCaller, mutableScannerEvents) - } - - test("startCameraScanner should emit InProgress event and launch scanner") { - sunmiCameraScanner.startCameraScanner("testScanKey") - - mutableScannerEvents.value.shouldBeInstanceOf() - (mutableScannerEvents.value as ScannerEvent.Camera.InProgress).scanKey shouldBe "testScanKey" - verify { scannerLauncher.launch(Unit) } - } - - test("registerForActivityResult callback emits ScanEvent_Canceled") { - val callbackSlot = slot>>() - every { - resultCaller.registerForActivityResult>(any(), capture(callbackSlot)) - } returns scannerLauncher - - sunmiCameraScanner = SunmiCameraScanner(resultCaller, mutableScannerEvents) - - val events = listOf(ScannerEvent.Camera.Canceled) - callbackSlot.captured.onActivityResult(events) - - val testResults = mutableListOf() - testScope.launch { - sunmiCameraScanner.observeScannerResults().toList(testResults) - } - sunmiCameraScanner.startCameraScanner() - testResults.first() shouldBe ScannerEvent.Camera.Canceled - } - - test("scannerLauncher should emit Success event") { - val callbackSlot = slot>>() - every { - resultCaller.registerForActivityResult>(any(), capture(callbackSlot)) - } returns scannerLauncher - - sunmiCameraScanner = SunmiCameraScanner(resultCaller, mutableScannerEvents) - - val events = listOf(ScannerEvent.ScanResult("123456789", "key")) - callbackSlot.captured.onActivityResult(events) - - val testResults = mutableListOf() - testScope.launch { - sunmiCameraScanner.observeScannerResults().toList(testResults) - } - sunmiCameraScanner.startCameraScanner("key") - val result = testResults.first() - result.shouldBeInstanceOf() - result.value shouldBe "123456789" - } -}) diff --git a/scan-engine/src/test/java/de/tillhub/scanengine/zebra/ZebraPairBarcodeViewModelTest.kt b/scan-engine/src/test/java/de/tillhub/scanengine/zebra/ZebraPairBarcodeViewModelTest.kt deleted file mode 100644 index 5c47621..0000000 --- a/scan-engine/src/test/java/de/tillhub/scanengine/zebra/ZebraPairBarcodeViewModelTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package de.tillhub.scanengine.zebra - -import com.zebra.scannercontrol.SDKHandler -import de.tillhub.scanengine.ScanEngine -import de.tillhub.scanengine.barcode.BarcodeScannerContainer -import de.tillhub.scanengine.common.ViewModelFunSpec -import de.tillhub.scanengine.data.ScannerEvent -import io.kotest.matchers.shouldBe -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf - -@ExperimentalCoroutinesApi -class ZebraPairBarcodeViewModelTest : ViewModelFunSpec({ - - lateinit var scanEngine: ScanEngine - lateinit var zebraScanner: ZebraBarcodeScanner - lateinit var barcodeScannerContainer: BarcodeScannerContainer - beforeTest { - scanEngine = mockk(relaxed = true) - zebraScanner = mockk(relaxed = true) - barcodeScannerContainer = mockk(relaxed = true) - mockkObject(ScanEngine) - } - - test("initial state is Loading") { - every { scanEngine.barcodeScanner } returns barcodeScannerContainer - every { scanEngine.observeScannerResults() } returns flowOf() - every { barcodeScannerContainer.getScannersByType(ZebraBarcodeScanner::class.java) } returns zebraScanner - - val viewModel = ZebraPairBarcodeViewModel(scanEngine) - viewModel.uiStateFlow.value shouldBe ZebraPairBarcodeViewModel.State.Loading - } - - test("state transitions to Connected on ScanEvent.Connected") { - val scannerEvents = MutableStateFlow(ScannerEvent.External.NotConnected) - - every { scanEngine.barcodeScanner } returns barcodeScannerContainer - every { barcodeScannerContainer.getScannersByType(ZebraBarcodeScanner::class.java) } returns zebraScanner - every { scanEngine.observeScannerResults() } returns scannerEvents - - val viewModel = ZebraPairBarcodeViewModel(scanEngine) - - scannerEvents.tryEmit(ScannerEvent.External.Connected) - - viewModel.uiStateFlow.value shouldBe ZebraPairBarcodeViewModel.State.Connected - } - - test("initScanner sets state to Pairing") { - val result = mockk>(relaxed = true) - - every { scanEngine.barcodeScanner } returns barcodeScannerContainer - every { barcodeScannerContainer.getScannersByType(ZebraBarcodeScanner::class.java) } returns zebraScanner - coEvery { zebraScanner.initScanner() } returns result - - val viewModel = ZebraPairBarcodeViewModel(scanEngine) - - viewModel.initScanner() - - viewModel.uiStateFlow.value shouldBe ZebraPairBarcodeViewModel.State.Pairing(result) - } -}) diff --git a/settings.gradle.kts b/settings.gradle.kts index aa7cafa..7ffeb90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,25 +1,20 @@ -import java.net.URI - +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { repositories { google() - mavenCentral() gradlePluginPortal() + mavenCentral() } } -@Suppress("UnstableApiUsage") dependencyResolutionManagement { repositories { google() mavenCentral() - maven { - url = URI.create("https://jitpack.io") - } + maven("https://plugins.gradle.org/m2/") } } - -rootProject.name = "Tillhub Scan Engine" +rootProject.name = "Tillhub_Scan_Engine" +include(":shared") include(":sample") -include(":scan-engine") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..9e60407 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,66 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) +} + +kotlin { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + // removes warnings for expect/actual classes + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "shared" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.bundles.core) + + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(libs.bundles.camera) + implementation(libs.bundles.mlkit) + implementation(libs.activity.compose) + } + } +} + +android { + namespace = "de.tillhub.scanengine" + compileSdk = 35 + defaultConfig { + minSdk = 24 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..48182e0 --- /dev/null +++ b/shared/src/androidMain/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt b/shared/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt new file mode 100644 index 0000000..6f106e9 --- /dev/null +++ b/shared/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt @@ -0,0 +1,8 @@ +package de.tillhub.scanengine + +class AndroidPlatform : Platform { + override val name: String = "Android ${android.os.Build.VERSION.SDK_INT}" + override val manufacturer: String = android.os.Build.MANUFACTURER +} + +actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt b/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt new file mode 100644 index 0000000..38836e5 --- /dev/null +++ b/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt @@ -0,0 +1,61 @@ +package de.tillhub.scanengine.camera + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +class AndroidPermissionHandler( + private val context: Context +) : PermissionHandler { + override fun hasCameraPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + @Composable + override fun RequestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + if (isGranted) { + onGranted() + } else { + onDenied() + } + } + ) + + val permissionStatus = ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) + + when (permissionStatus) { + PackageManager.PERMISSION_GRANTED -> onGranted() + PackageManager.PERMISSION_DENIED -> { + + LaunchedEffect(Unit) { + launcher.launch(Manifest.permission.CAMERA) + } + } + } + } +} + +@Composable +actual fun getPermissionHandler(): PermissionHandler { + val context = LocalContext.current + + return remember { + AndroidPermissionHandler(context) + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt new file mode 100644 index 0000000..e193243 --- /dev/null +++ b/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -0,0 +1,140 @@ +package de.tillhub.scanengine.camera + +import android.content.Context +import android.media.Image +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.coroutines.flow.MutableStateFlow + +actual class CameraController( + private val context: Context, + private val lifecycleOwner: LifecycleOwner, + private val scannerEvents: MutableStateFlow, + scanner: BarcodeScanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build() + ) +) { + private var scanKey: String? = null; + + private val analyzer: ImageAnalysis.Analyzer = QRImageAnalyzer( + scanner = scanner, + inputImageGenerator = InputImageGenerator() + ) { barcode -> + scannerEvents.value = ScannerEvent.ScanResult(barcode, scanKey) + scanKey = null + } + + private var cameraProvider: ProcessCameraProvider? = null + private var preview: Preview? = null + private var camera: Camera? = null + private var previewView: PreviewView? = null + + private val executor = ContextCompat.getMainExecutor(context) + + fun bindCamera(previewView: PreviewView, onCameraReady: () -> Unit = {}) { + this.previewView = previewView + + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener( + { + cameraProvider = cameraProviderFuture.get() + cameraProvider?.unbindAll() + + preview = Preview.Builder() + .setResolutionSelector(createResolutionSelector()) + .build() + .also { + it.surfaceProvider = previewView.surfaceProvider + } + + val imageAnalyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer(executor, analyzer) + } + + camera = cameraProvider?.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer + ) + + onCameraReady() + + }, + executor + ) + } + + /** + * Starts the camera session. + */ + actual fun startSession() = Unit + + /** + * Stops the camera session. + */ + actual fun stopSession() { + cameraProvider?.unbindAll() + } + + actual fun setScanKey(scanKey: String?) { + this.scanKey = scanKey + } + + + private fun createResolutionSelector(): ResolutionSelector { + return ResolutionSelector.Builder() + .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) + .build() + } +} + +internal class QRImageAnalyzer( + private val scanner: BarcodeScanner, + private val inputImageGenerator: InputImageGenerator, + private val barcodeScanned: (String) -> Unit +) : ImageAnalysis.Analyzer { + + @androidx.camera.core.ExperimentalGetImage + override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = inputImageGenerator.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + + scanner.process(image).addOnSuccessListener { list -> + if (list.size > 0 && list[0].rawValue != null) { + barcodeScanned(list[0].rawValue!!) + scanner.close() + } + }.addOnCompleteListener { + imageProxy.close() + } + } + } +} + +internal class InputImageGenerator { + fun fromMediaImage(mediaImage: Image, rotationDegrees: Int): InputImage { + return InputImage.fromMediaImage(mediaImage, rotationDegrees) + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt b/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt new file mode 100644 index 0000000..9af851e --- /dev/null +++ b/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt @@ -0,0 +1,42 @@ +package de.tillhub.scanengine.camera.ui + +import androidx.camera.view.PreviewView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import de.tillhub.scanengine.ScanEngine +import de.tillhub.scanengine.camera.CameraController +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun cameraPreview( + modifier: Modifier, + scanKey: String?, + scannerEvents: MutableStateFlow +) { + val context = LocalContext.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + + val previewView = remember { PreviewView(context) } + val controller = remember { + CameraController(context, lifecycleOwner, scannerEvents).also { + it.setScanKey(scanKey) + } + } + + DisposableEffect(previewView) { + controller.bindCamera(previewView) {} + onDispose { + controller.stopSession() + } + } + + AndroidView( + factory = { previewView }, + modifier = modifier, + ) +} \ No newline at end of file diff --git a/scan-engine/src/main/java/de/tillhub/scanengine/BarcodeScanner.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt similarity index 100% rename from scan-engine/src/main/java/de/tillhub/scanengine/BarcodeScanner.kt rename to shared/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt diff --git a/scan-engine/src/main/java/de/tillhub/scanengine/CameraScanner.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt similarity index 100% rename from scan-engine/src/main/java/de/tillhub/scanengine/CameraScanner.kt rename to shared/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt diff --git a/scan-engine/src/main/java/de/tillhub/scanengine/KeyEventScanner.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt similarity index 73% rename from scan-engine/src/main/java/de/tillhub/scanengine/KeyEventScanner.kt rename to shared/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt index 3472d66..cb9b245 100644 --- a/scan-engine/src/main/java/de/tillhub/scanengine/KeyEventScanner.kt +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt @@ -1,6 +1,6 @@ package de.tillhub.scanengine -import android.view.KeyEvent +import androidx.compose.ui.input.key.KeyEvent interface KeyEventScanner { fun dispatchKeyEvent(event: KeyEvent, scanKey: String? = null) diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt new file mode 100644 index 0000000..0be5833 --- /dev/null +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt @@ -0,0 +1,8 @@ +package de.tillhub.scanengine + +interface Platform { + val name: String + val manufacturer: String +} + +expect fun getPlatform(): Platform \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt new file mode 100644 index 0000000..d163f3d --- /dev/null +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt @@ -0,0 +1,28 @@ +package de.tillhub.scanengine + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import de.tillhub.scanengine.camera.ui.CameraScreen +import de.tillhub.scanengine.common.SingletonHolder +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop + +class ScanEngine private constructor() { + + private val mutableScannerEvents = MutableStateFlow(ScannerEvent.External.NotConnected) + + fun observeScannerResults(): Flow = mutableScannerEvents.drop(1) + + @Composable + fun getCameraView(scanKey: String?, modifier: Modifier = Modifier) { + CameraScreen( + modifier = modifier, + scanKey = scanKey, + scannerEvents = mutableScannerEvents + ) + } + + companion object : SingletonHolder(::ScanEngine) +} diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt new file mode 100644 index 0000000..a0256e7 --- /dev/null +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt @@ -0,0 +1,15 @@ +package de.tillhub.scanengine.camera + +expect class CameraController { + /** + * Starts the camera session. + */ + fun startSession() + + /** + * Stops the camera session. + */ + fun stopSession() + + fun setScanKey(scanKey: String?) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt new file mode 100644 index 0000000..f1cb705 --- /dev/null +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt @@ -0,0 +1,24 @@ +package de.tillhub.scanengine.camera + +import androidx.compose.runtime.Composable + +interface PermissionHandler { + /** + * Checks if the camera permission is granted. + * + * @return True if granted, false otherwise. + */ + fun hasCameraPermission(): Boolean + + /** + * Requests camera permission. + * + * @param onGranted Callback invoked when permission is granted. + * @param onDenied Callback invoked when permission is denied. + */ + @Composable + fun RequestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) +} + +@Composable +expect fun getPermissionHandler(): PermissionHandler \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt new file mode 100644 index 0000000..626e3d4 --- /dev/null +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -0,0 +1,43 @@ +package de.tillhub.scanengine.camera.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import de.tillhub.scanengine.camera.PermissionHandler +import de.tillhub.scanengine.camera.getPermissionHandler +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.coroutines.flow.MutableStateFlow +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Preview +@Composable +fun CameraScreen( + modifier: Modifier = Modifier, + scanKey: String? = null, + scannerEvents: MutableStateFlow +) { + val permissions: PermissionHandler = getPermissionHandler() + + val hasPermission = remember { mutableStateOf(permissions.hasCameraPermission()) } + + if (hasPermission.value) { + cameraPreview(modifier, scanKey, scannerEvents) + } else { + permissions.RequestCameraPermission( + onGranted = { + hasPermission.value = true + }, + onDenied = { + // TODO + } + ) + } +} + +@Composable +expect fun cameraPreview( + modifier: Modifier, + scanKey: String?, + scannerEvents: MutableStateFlow +) diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt new file mode 100644 index 0000000..ee9efed --- /dev/null +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt @@ -0,0 +1,32 @@ +package de.tillhub.scanengine.common + +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.internal.SynchronizedObject +import kotlinx.coroutines.internal.synchronized +import kotlin.concurrent.Volatile + +@OptIn(InternalCoroutinesApi::class) +open class SingletonHolder(creator: () -> T) : SynchronizedObject() { + private var creator: (() -> T)? = creator + @Volatile + private var instance: T? = null + + fun getInstance(): T { + val checkInstance = instance + if (checkInstance != null) { + return checkInstance + } + + return synchronized(this) { + val checkInstanceAgain = instance + if (checkInstanceAgain != null) { + checkInstanceAgain + } else { + val created = creator!!() + instance = created + creator = null + created + } + } + } +} diff --git a/scan-engine/src/main/java/de/tillhub/scanengine/data/Scanner.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt similarity index 100% rename from scan-engine/src/main/java/de/tillhub/scanengine/data/Scanner.kt rename to shared/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt diff --git a/scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerEvent.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt similarity index 100% rename from scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerEvent.kt rename to shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt diff --git a/scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerResponse.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt similarity index 76% rename from scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerResponse.kt rename to shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt index 950790d..831f978 100644 --- a/scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerResponse.kt +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt @@ -1,12 +1,11 @@ package de.tillhub.scanengine.data -import androidx.annotation.DrawableRes sealed class ScannerResponse { sealed class Error : ScannerResponse() { data object NotFound : Error() data object Disconnect : Error() - class Connect(@DrawableRes val barcode: Int) : Error() + class Connect(val barcode: Int) : Error() // TODO [barcode] should be } sealed class Success : ScannerResponse() { diff --git a/scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerType.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt similarity index 73% rename from scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerType.kt rename to shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt index 4e8008d..a2c86ef 100644 --- a/scan-engine/src/main/java/de/tillhub/scanengine/data/ScannerType.kt +++ b/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt @@ -1,5 +1,7 @@ package de.tillhub.scanengine.data +import de.tillhub.scanengine.getPlatform + enum class ScannerType(val value: String) { SUNMI("SUNMI"), ZEBRA("ZEBRA"), @@ -8,7 +10,7 @@ enum class ScannerType(val value: String) { companion object { fun get(): ScannerType = entries.firstOrNull { - it.value == android.os.Build.MANUFACTURER + it.value == getPlatform().manufacturer } ?: UNKNOWN } } diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt b/shared/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt new file mode 100644 index 0000000..d4335d9 --- /dev/null +++ b/shared/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt @@ -0,0 +1,10 @@ +package de.tillhub.scanengine + +import platform.UIKit.UIDevice + +class IOSPlatform: Platform { + override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion + override val manufacturer: String = "apple" +} + +actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt new file mode 100644 index 0000000..aa5a09b --- /dev/null +++ b/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -0,0 +1,18 @@ +package de.tillhub.scanengine.camera + +actual class CameraController { + /** + * Starts the camera session. + */ + actual fun startSession() { + } + + /** + * Stops the camera session. + */ + actual fun stopSession() { + } + + actual fun setScanKey(scanKey: String?) { + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt b/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt new file mode 100644 index 0000000..0d01d84 --- /dev/null +++ b/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt @@ -0,0 +1,37 @@ +package de.tillhub.scanengine.camera + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.AVFoundation.AVAuthorizationStatusAuthorized +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVMediaTypeVideo +import platform.AVFoundation.authorizationStatusForMediaType +import platform.AVFoundation.requestAccessForMediaType + +class IosPermissionHandler : PermissionHandler { + override fun hasCameraPermission(): Boolean { + val status = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) + return status == AVAuthorizationStatusAuthorized + } + + @Composable + override fun RequestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { + AVCaptureDevice.requestAccessForMediaType( + AVMediaTypeVideo + ) { granted -> + if (granted) { + onGranted() + } else { + onDenied() + } + } + } + +} + +@Composable +actual fun getPermissionHandler(): PermissionHandler { + return remember { + IosPermissionHandler() + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt new file mode 100644 index 0000000..36d256b --- /dev/null +++ b/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -0,0 +1,15 @@ +package de.tillhub.scanengine.camera.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun cameraPreview( + modifier: Modifier, + scanKey: String?, + scannerEvents: MutableStateFlow +) { + +} \ No newline at end of file From 8fb04a23d8b12c05c61e3de5e82d58ad0409812e Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 19 Jun 2025 14:05:13 +0200 Subject: [PATCH 02/19] [UNTIL-14107] working android prototype --- .gitignore | 2 +- build.gradle.kts | 1 + gradle/libs.versions.toml | 3 + sample/build.gradle.kts | 57 ++++++--- .../{main => androidMain}/AndroidManifest.xml | 0 .../tillhub/scanengine/sample/MainActivity.kt | 14 +++ .../res/drawable/ic_launcher_background.xml | 0 .../res/drawable/ic_launcher_foreground.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../res/mipmap-hdpi/ic_launcher.webp | Bin .../res/mipmap-hdpi/ic_launcher_round.webp | Bin .../res/mipmap-mdpi/ic_launcher.webp | Bin .../res/mipmap-mdpi/ic_launcher_round.webp | Bin .../res/mipmap-xhdpi/ic_launcher.webp | Bin .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin .../res/mipmap-xxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin .../res/mipmap-xxxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin .../res/values/styles.xml | 0 .../de/tillhub/scanengine/sample/App.kt | 117 ++++++++++++++++++ .../tillhub/scanengine/sample}/theme/Color.kt | 2 +- .../tillhub/scanengine/sample}/theme/Theme.kt | 2 +- .../tillhub/scanengine/sample}/theme/Type.kt | 2 +- .../scanengine/sample/MainViewController.kt | 5 + .../tillhub/scanengine/sample/MainActivity.kt | 46 ------- {shared => scan_engine}/build.gradle.kts | 36 +++--- .../src/androidMain/AndroidManifest.xml | 4 + .../de/tillhub/scanengine/Platform.android.kt | 0 .../camera/AndroidPermissionHandler.kt | 0 .../camera/CameraController.android.kt | 10 +- .../contract/CameraScanContract.android.kt | 54 ++++++++ .../camera/ui/CameraScanActivity.kt | 32 +++++ .../camera/ui/CameraScreen.android.kt | 7 +- .../composeResources/values-de/strings.xml | 15 +++ .../composeResources/values/strings.xml | 17 +++ .../de/tillhub/scanengine/BarcodeScanner.kt | 0 .../de/tillhub/scanengine/CameraScanner.kt | 6 +- .../de/tillhub/scanengine/KeyEventScanner.kt | 0 .../kotlin/de/tillhub/scanengine/Platform.kt | 0 .../de/tillhub/scanengine/ScanEngine.kt | 11 +- .../scanengine/camera/CameraController.kt | 0 .../scanengine/camera/DefaultCameraScanner.kt | 33 +++++ .../scanengine/camera/PermissionHandler.kt | 0 .../camera/contract/CameraScanContract.kt | 13 ++ .../scanengine/camera/ui/CameraScreen.kt | 95 ++++++++++++++ .../scanengine/common/SingletonHolder.kt | 0 .../de/tillhub/scanengine/data/Scanner.kt | 0 .../tillhub/scanengine/data/ScannerEvent.kt | 0 .../scanengine/data/ScannerResponse.kt | 0 .../de/tillhub/scanengine/data/ScannerType.kt | 0 .../scanengine/ui/components/BottomButton.kt | 43 +++++++ .../ui/components/TabletExtensions.kt | 26 ++++ .../scanengine/ui/components/Toolbar.kt | 57 +++++++++ .../de/tillhub/scanengine/ui/theme/Color.kt | 12 ++ .../de/tillhub/scanengine/ui/theme/Style.kt | 34 +++++ .../de/tillhub/scanengine/ui/theme/Theme.kt | 14 +++ .../de/tillhub/scanengine/ui/theme/Type.kt | 99 +++++++++++++++ .../de/tillhub/scanengine/Platform.ios.kt | 0 .../scanengine/camera/CameraController.ios.kt | 4 +- .../scanengine/camera/IosPermissionHandler.kt | 0 .../camera/contract/CameraScanContract.ios.kt | 50 ++++++++ .../scanengine/camera/ui/CameraScreen.ios.kt | 3 +- settings.gradle.kts | 2 +- .../scanengine/camera/ui/CameraScreen.kt | 43 ------- 66 files changed, 823 insertions(+), 148 deletions(-) rename sample/src/{main => androidMain}/AndroidManifest.xml (100%) create mode 100644 sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt rename sample/src/{main => androidMain}/res/drawable/ic_launcher_background.xml (100%) rename sample/src/{main => androidMain}/res/drawable/ic_launcher_foreground.xml (100%) rename sample/src/{main => androidMain}/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename sample/src/{main => androidMain}/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename sample/src/{main => androidMain}/res/mipmap-hdpi/ic_launcher.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-hdpi/ic_launcher_round.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-mdpi/ic_launcher.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-mdpi/ic_launcher_round.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-xhdpi/ic_launcher.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-xhdpi/ic_launcher_round.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-xxhdpi/ic_launcher.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-xxhdpi/ic_launcher_round.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-xxxhdpi/ic_launcher.webp (100%) rename sample/src/{main => androidMain}/res/mipmap-xxxhdpi/ic_launcher_round.webp (100%) rename sample/src/{main => androidMain}/res/values/styles.xml (100%) create mode 100644 sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt rename sample/src/{main/java/de/tillhub/scanengine/sample/ui => commonMain/kotlin/de/tillhub/scanengine/sample}/theme/Color.kt (84%) rename sample/src/{main/java/de/tillhub/scanengine/sample/ui => commonMain/kotlin/de/tillhub/scanengine/sample}/theme/Theme.kt (97%) rename sample/src/{main/java/de/tillhub/scanengine/sample/ui => commonMain/kotlin/de/tillhub/scanengine/sample}/theme/Type.kt (95%) create mode 100644 sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt delete mode 100644 sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt rename {shared => scan_engine}/build.gradle.kts (69%) rename {shared => scan_engine}/src/androidMain/AndroidManifest.xml (79%) rename {shared => scan_engine}/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt (100%) rename {shared => scan_engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt (100%) rename {shared => scan_engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt (95%) create mode 100644 scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt create mode 100644 scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt rename {shared => scan_engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt (84%) create mode 100644 scan_engine/src/commonMain/composeResources/values-de/strings.xml create mode 100644 scan_engine/src/commonMain/composeResources/values/strings.xml rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt (100%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt (53%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt (100%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt (100%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt (74%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt (100%) create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt (100%) create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt (100%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt (100%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt (100%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt (100%) rename {shared => scan_engine}/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt (100%) create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt create mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt rename {shared => scan_engine}/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt (100%) rename {shared => scan_engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt (72%) rename {shared => scan_engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt (100%) create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt rename {shared => scan_engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt (79%) delete mode 100644 shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt diff --git a/.gitignore b/.gitignore index 30f9a58..1996883 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ out/ # Gradle files .gradle/ /build -/shared/build +/scan_engine/build /sample/build # Local configuration file (sdk path, etc) diff --git a/build.gradle.kts b/build.gradle.kts index b7a6267..e670925 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { //trick: for the same plugin versions in all sub-modules alias(libs.plugins.android.library).apply(false) alias(libs.plugins.kotlinMultiplatform).apply(false) + alias(libs.plugins.androidKotlinMultiplatformLibrary).apply(false) alias(libs.plugins.compose.compiler).apply(false) alias(libs.plugins.compose).apply(false) alias(libs.plugins.atomicfu).apply(false) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36836ff..28beeaf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,8 @@ camera-extensions = { module = "androidx.camera:camera-extensions", version.ref camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera-lifecycle" } camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camera-view" } + +androidx-lifecycle-runtimeCompose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } @@ -36,6 +38,7 @@ google-play-services-mlkit = { group = "com.google.android.gms", name = "play-se [plugins] android_library = { id = "com.android.library", version.ref = "agp" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 1bcf3e5..3190928 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -2,11 +2,51 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.compose.compiler) alias(libs.plugins.compose) } +kotlin { + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + + androidMain.dependencies { + implementation(libs.activity.compose) + implementation(compose.preview) + } + commonMain.dependencies { + implementation(projects.scanEngine) + + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(compose.ui) + implementation(compose.uiTooling) + + implementation(libs.lifecycle.runtime.ktx) + } + } +} + android { namespace = "de.tillhub.scanengine.sample" compileSdk = 35 @@ -34,24 +74,11 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JvmTarget.JVM_17.target - } buildFeatures { compose = true } } dependencies { - implementation(projects.shared) - - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.components.resources) - implementation(compose.components.uiToolingPreview) - implementation(compose.ui) - implementation(compose.uiTooling) - implementation(libs.lifecycle.runtime.ktx) - implementation(libs.activity.compose) + } \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/androidMain/AndroidManifest.xml similarity index 100% rename from sample/src/main/AndroidManifest.xml rename to sample/src/androidMain/AndroidManifest.xml diff --git a/sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt b/sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt new file mode 100644 index 0000000..0d00878 --- /dev/null +++ b/sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt @@ -0,0 +1,14 @@ +package de.tillhub.scanengine.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { App() } + } +} \ No newline at end of file diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/androidMain/res/drawable/ic_launcher_background.xml similarity index 100% rename from sample/src/main/res/drawable/ic_launcher_background.xml rename to sample/src/androidMain/res/drawable/ic_launcher_background.xml diff --git a/sample/src/main/res/drawable/ic_launcher_foreground.xml b/sample/src/androidMain/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from sample/src/main/res/drawable/ic_launcher_foreground.xml rename to sample/src/androidMain/res/drawable/ic_launcher_foreground.xml diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to sample/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.webp b/sample/src/androidMain/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from sample/src/main/res/mipmap-hdpi/ic_launcher.webp rename to sample/src/androidMain/res/mipmap-hdpi/ic_launcher.webp diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sample/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to sample/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher.webp b/sample/src/androidMain/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from sample/src/main/res/mipmap-mdpi/ic_launcher.webp rename to sample/src/androidMain/res/mipmap-mdpi/ic_launcher.webp diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/sample/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to sample/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sample/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from sample/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to sample/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sample/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to sample/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to sample/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to sample/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/sample/src/main/res/values/styles.xml b/sample/src/androidMain/res/values/styles.xml similarity index 100% rename from sample/src/main/res/values/styles.xml rename to sample/src/androidMain/res/values/styles.xml diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt new file mode 100644 index 0000000..a303634 --- /dev/null +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt @@ -0,0 +1,117 @@ +package de.tillhub.scanengine.sample + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +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.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.tillhub.scanengine.ScanEngine +import de.tillhub.scanengine.data.ScannerEvent +import de.tillhub.scanengine.sample.theme.Tillhub_Scan_EngineTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +@Preview +fun App() { + var currentScreen by remember { mutableStateOf(null) } + + val scanEngine by lazy { ScanEngine.getInstance() } + val scannedResult = remember { mutableStateOf("No result") } + + val cameraScannerLauncher = scanEngine.newCameraScanner().cameraScannerLauncher() + + CoroutineScope(Job() + Dispatchers.Main).launch { + scanEngine.observeScannerResults().collect { + (it as? ScannerEvent.ScanResult)?.let { result -> + scannedResult.value = result.value + } + } + } + + Tillhub_Scan_EngineTheme { + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.systemBars.asPaddingValues()) + .padding(16.dp) + ) { + Column { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .padding(16.dp) + .wrapContentWidth(), + text = "Results : ${scannedResult.value}", + fontSize = 20.sp, + style = MaterialTheme.typography.titleMedium + ) + } + + ScanSection("Camera scan") { + currentScreen = ScanScreen.CameraScan + } + Spacer(modifier = Modifier.height(16.dp)) + } + + when (currentScreen) { + ScanScreen.CameraScan -> LaunchedEffect(Unit) { + cameraScannerLauncher.launchCameraScanner() + currentScreen = null + } + else -> {} + } + } + } +} + +@Composable +fun ScanSection( + label: String, + onClick: () -> Unit +) { + Column { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onClick + ) { + Text(text = label) + } + } +} + +enum class ScanScreen { + CameraScan, Bluetooth +} \ No newline at end of file diff --git a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Color.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Color.kt similarity index 84% rename from sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Color.kt rename to sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Color.kt index c32392d..9999178 100644 --- a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Color.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Color.kt @@ -1,4 +1,4 @@ -package de.tillhub.scanengine.sample.ui.theme +package de.tillhub.scanengine.sample.theme import androidx.compose.ui.graphics.Color diff --git a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Theme.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt similarity index 97% rename from sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Theme.kt rename to sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt index 1fa9b54..62ffd28 100644 --- a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Theme.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt @@ -1,4 +1,4 @@ -package de.tillhub.scanengine.sample.ui.theme +package de.tillhub.scanengine.sample.theme import android.app.Activity import android.os.Build diff --git a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Type.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt similarity index 95% rename from sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Type.kt rename to sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt index 09305f3..af23b8b 100644 --- a/sample/src/main/java/de/tillhub/scanengine/sample/ui/theme/Type.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt @@ -1,4 +1,4 @@ -package de.tillhub.scanengine.sample.ui.theme +package de.tillhub.scanengine.sample.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle diff --git a/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt b/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt new file mode 100644 index 0000000..4e5d914 --- /dev/null +++ b/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt @@ -0,0 +1,5 @@ +package de.tillhub.scanengine.sample + +import androidx.compose.ui.window.ComposeUIViewController + +fun MainViewController() = ComposeUIViewController { App() } \ No newline at end of file diff --git a/sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt b/sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt deleted file mode 100644 index a939278..0000000 --- a/sample/src/main/java/de/tillhub/scanengine/sample/MainActivity.kt +++ /dev/null @@ -1,46 +0,0 @@ -package de.tillhub.scanengine.sample - -import android.os.Bundle -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.ui.Modifier -import androidx.lifecycle.lifecycleScope -import de.tillhub.scanengine.ScanEngine -import de.tillhub.scanengine.camera.ui.CameraScreen -import de.tillhub.scanengine.data.ScannerEvent -import de.tillhub.scanengine.sample.ui.theme.Tillhub_Scan_EngineTheme -import kotlinx.coroutines.launch - -class MainActivity : ComponentActivity() { - private val scanEngine by lazy { - ScanEngine.getInstance() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - Tillhub_Scan_EngineTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - scanEngine.getCameraView( - scanKey = "camera_scan_key", - modifier = Modifier.padding(innerPadding) - ) - } - } - } - - lifecycleScope.launch { - scanEngine.observeScannerResults().collect { - (it as? ScannerEvent.ScanResult)?.let { result -> - Log.d("SCANNER", result.scanKey + " " + result.value) - } - } - } - } -} \ No newline at end of file diff --git a/shared/build.gradle.kts b/scan_engine/build.gradle.kts similarity index 69% rename from shared/build.gradle.kts rename to scan_engine/build.gradle.kts index 9e60407..a34e456 100644 --- a/shared/build.gradle.kts +++ b/scan_engine/build.gradle.kts @@ -1,24 +1,30 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import com.android.build.api.dsl.androidLibrary plugins { alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.android.library) - alias(libs.plugins.compose.compiler) + alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.compose) + alias(libs.plugins.compose.compiler) } kotlin { - androidTarget { + targets.all { compilations.all { compileTaskProvider.configure { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) // removes warnings for expect/actual classes freeCompilerArgs.add("-Xexpect-actual-classes") } } } } + + androidLibrary { + namespace = "de.tillhub.scanengine" + compileSdk = 35 + minSdk = 24 + experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true + } listOf( iosX64(), @@ -26,7 +32,7 @@ kotlin { iosSimulatorArm64() ).forEach { it.binaries.framework { - baseName = "shared" + baseName = "scan_engine" isStatic = true } } @@ -38,9 +44,12 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) + implementation(compose.materialIconsExtended) implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + + implementation(libs.androidx.lifecycle.runtimeCompose) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -53,14 +62,7 @@ kotlin { } } -android { - namespace = "de.tillhub.scanengine" - compileSdk = 35 - defaultConfig { - minSdk = 24 - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -} +compose.resources { + packageOfResClass = "de.tillhub.scanengine.resources" + generateResClass = auto +} \ No newline at end of file diff --git a/shared/src/androidMain/AndroidManifest.xml b/scan_engine/src/androidMain/AndroidManifest.xml similarity index 79% rename from shared/src/androidMain/AndroidManifest.xml rename to scan_engine/src/androidMain/AndroidManifest.xml index 48182e0..186ad34 100644 --- a/shared/src/androidMain/AndroidManifest.xml +++ b/scan_engine/src/androidMain/AndroidManifest.xml @@ -6,6 +6,10 @@ + + diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt similarity index 100% rename from shared/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt rename to scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt similarity index 100% rename from shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt rename to scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt similarity index 95% rename from shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt rename to scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt index e193243..7cc228b 100644 --- a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow actual class CameraController( private val context: Context, private val lifecycleOwner: LifecycleOwner, - private val scannerEvents: MutableStateFlow, + barcodeScanned: (String) -> Unit, scanner: BarcodeScanner = BarcodeScanning.getClient( BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build() ) @@ -34,11 +34,9 @@ actual class CameraController( private val analyzer: ImageAnalysis.Analyzer = QRImageAnalyzer( scanner = scanner, - inputImageGenerator = InputImageGenerator() - ) { barcode -> - scannerEvents.value = ScannerEvent.ScanResult(barcode, scanKey) - scanKey = null - } + inputImageGenerator = InputImageGenerator(), + barcodeScanned = barcodeScanned + ) private var cameraProvider: ProcessCameraProvider? = null private var preview: Preview? = null diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt new file mode 100644 index 0000000..5a54d28 --- /dev/null +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt @@ -0,0 +1,54 @@ +package de.tillhub.scanengine.camera.contract + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import de.tillhub.scanengine.camera.ui.CameraScanActivity +import de.tillhub.scanengine.data.ScannerEvent + +@Composable +actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit): CameraScanContract { + val context = LocalContext.current + val launchCallback = remember { mutableStateOf<((String?) -> Unit)?>(null) } + + var scanKey: String? = null + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val resultData = result.data?.extras?.getString(CameraScanActivity.DATA_KEY) + + val scanResult = if (result.resultCode == Activity.RESULT_OK && !resultData.isNullOrEmpty()) { + ScannerEvent.ScanResult( + value = resultData, + scanKey = scanKey + ) + } else { + ScannerEvent.Camera.Canceled + } + + onResult(scanResult) + } + + LaunchedEffect(Unit) { + launchCallback.value = { + scanKey = it + val intent = Intent(context, CameraScanActivity::class.java) + launcher.launch(intent) + } + } + + return remember { + object : CameraScanContract { + override fun launchCameraScanner(scanKey: String?) { + launchCallback.value?.invoke(scanKey) + } + } + } +} \ No newline at end of file diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt new file mode 100644 index 0000000..25f79f1 --- /dev/null +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt @@ -0,0 +1,32 @@ +package de.tillhub.scanengine.camera.ui + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +class CameraScanActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + CameraScreen( + onResult = { barcode -> + val resultIntent = Intent().apply { + putExtra(DATA_KEY, barcode) + } + setResult(RESULT_OK, resultIntent) + finish() + }, + onDismiss = { + setResult(RESULT_CANCELED) + finish() + } + ) + } + } + + companion object { + const val DATA_KEY = "scanned_data" + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt similarity index 84% rename from shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt rename to scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt index 9af851e..ffdd8bf 100644 --- a/shared/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt @@ -15,17 +15,14 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable actual fun cameraPreview( modifier: Modifier, - scanKey: String?, - scannerEvents: MutableStateFlow + barcodeScanned: (String) -> Unit ) { val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val previewView = remember { PreviewView(context) } val controller = remember { - CameraController(context, lifecycleOwner, scannerEvents).also { - it.setScanKey(scanKey) - } + CameraController(context, lifecycleOwner, barcodeScanned) } DisposableEffect(previewView) { diff --git a/scan_engine/src/commonMain/composeResources/values-de/strings.xml b/scan_engine/src/commonMain/composeResources/values-de/strings.xml new file mode 100644 index 0000000..6b49ba3 --- /dev/null +++ b/scan_engine/src/commonMain/composeResources/values-de/strings.xml @@ -0,0 +1,15 @@ + + Bluetooth-Fehler! + Mindestanforderungen nicht erfüllt. + + Barcode Scannen + Berechtigungen + Um den Scanner nutzen zu können, müssen Sie den Zugriff im nächsten Berechtigungsdialog gewähren.\n\nMöchten Sie fortfahren? + Kameraberechtigungen + + OK + Laden + Barcode-Kopplung + Barcode-Kopplung erfolgreich + Scannen Sie den Barcode, um Ihren Scanner mit der App zu verbinden. + \ No newline at end of file diff --git a/scan_engine/src/commonMain/composeResources/values/strings.xml b/scan_engine/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..89f88fa --- /dev/null +++ b/scan_engine/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,17 @@ + + + Bluetooth error! + Device does not reach minimum requirement. + + Scan barcode + Grant permissions + In order to use barcode reader you need to grant access by accepting the next permission dialog.\n\nWould you like to continue? + Request camera permission + + OK + Loading + Barcode pairing + Barcode pairing successful + Please first scan the barcode and then proceed to the Bluetooth pairing screen. + "Bluetooth Settings" + \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt similarity index 53% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt index 14f5928..3aace97 100644 --- a/shared/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt @@ -1,9 +1,13 @@ package de.tillhub.scanengine +import androidx.compose.runtime.Composable +import de.tillhub.scanengine.camera.contract.CameraScanContract import de.tillhub.scanengine.data.ScannerEvent import kotlinx.coroutines.flow.StateFlow interface CameraScanner { fun observeScannerResults(): StateFlow - fun startCameraScanner(scanKey: String? = null) + + @Composable + fun cameraScannerLauncher(): CameraScanContract } diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt similarity index 74% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt index d163f3d..3a5e2f5 100644 --- a/shared/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt @@ -2,9 +2,11 @@ package de.tillhub.scanengine import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import de.tillhub.scanengine.camera.DefaultCameraScanner import de.tillhub.scanengine.camera.ui.CameraScreen import de.tillhub.scanengine.common.SingletonHolder import de.tillhub.scanengine.data.ScannerEvent +import de.tillhub.scanengine.data.ScannerType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.drop @@ -15,13 +17,8 @@ class ScanEngine private constructor() { fun observeScannerResults(): Flow = mutableScannerEvents.drop(1) - @Composable - fun getCameraView(scanKey: String?, modifier: Modifier = Modifier) { - CameraScreen( - modifier = modifier, - scanKey = scanKey, - scannerEvents = mutableScannerEvents - ) + fun newCameraScanner(): CameraScanner { + return DefaultCameraScanner(mutableScannerEvents) } companion object : SingletonHolder(::ScanEngine) diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt new file mode 100644 index 0000000..ca84e71 --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt @@ -0,0 +1,33 @@ +package de.tillhub.scanengine.camera + +import androidx.compose.runtime.Composable +import de.tillhub.scanengine.CameraScanner +import de.tillhub.scanengine.camera.contract.CameraScanContract +import de.tillhub.scanengine.camera.contract.rememberCameraScanLauncher +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class DefaultCameraScanner( + private val mutableScannerEvents: MutableStateFlow, +) : CameraScanner { + + override fun observeScannerResults(): StateFlow = mutableScannerEvents + + @Composable + override fun cameraScannerLauncher(): CameraScanContract = + rememberCameraScanLauncher { result -> + when (result) { + ScannerEvent.Camera.Canceled -> mutableScannerEvents.tryEmit(result) + is ScannerEvent.ScanResult -> { + val scanKey = (mutableScannerEvents.value as? ScannerEvent.Camera.InProgress)?.scanKey + mutableScannerEvents.tryEmit(result.copy(scanKey = scanKey)) + } + + is ScannerEvent.Camera.InProgress, + is ScannerEvent.External.Connecting, + ScannerEvent.External.NotConnected, + ScannerEvent.External.Connected -> Unit + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt new file mode 100644 index 0000000..6f80f5c --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt @@ -0,0 +1,13 @@ +package de.tillhub.scanengine.camera.contract + +import androidx.compose.runtime.Composable +import de.tillhub.scanengine.data.ScannerEvent + +interface CameraScanContract { + fun launchCameraScanner(scanKey: String? = null) +} + +@Composable +expect fun rememberCameraScanLauncher( + onResult: (ScannerEvent) -> Unit +): CameraScanContract \ No newline at end of file diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt new file mode 100644 index 0000000..665355b --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -0,0 +1,95 @@ +package de.tillhub.scanengine.camera.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.tillhub.scanengine.camera.PermissionHandler +import de.tillhub.scanengine.camera.getPermissionHandler +import de.tillhub.scanengine.ui.components.BottomButton +import de.tillhub.scanengine.ui.components.Toolbar +import de.tillhub.scanengine.ui.components.getModifierBasedOnDeviceType +import de.tillhub.scanengine.ui.theme.TabletScaffoldModifier +import de.tillhub.scanengine.ui.theme.AppTheme +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import de.tillhub.scanengine.resources.Res +import de.tillhub.scanengine.resources.camera_title +import de.tillhub.scanengine.resources.permission_camera_request + +@Preview +@Composable +fun CameraScreen( + onResult: (String) -> Unit, + onDismiss: () -> Unit +) { + val permissions: PermissionHandler = getPermissionHandler() + + val hasPermission = remember { mutableStateOf(permissions.hasCameraPermission()) } + val askForPermission = remember { mutableStateOf(false) } + + AppTheme { + Scaffold( + modifier = getModifierBasedOnDeviceType( + isTablet = TabletScaffoldModifier, + isMobile = Modifier + ), + topBar = { + Toolbar( + title = if (hasPermission.value) { + stringResource(Res.string.camera_title) + } else { + stringResource(Res.string.camera_title) + }, + onClick = { onDismiss() } + ) + } + ) { innerPadding -> + when { + hasPermission.value -> cameraPreview( + modifier = Modifier + .padding(innerPadding) + .padding(top = 16.dp), + barcodeScanned = onResult + ) + askForPermission.value -> permissions.RequestCameraPermission( + onGranted = { hasPermission.value = true }, + onDenied = { askForPermission.value = false } + ) + else -> { + Column( + modifier = Modifier + .padding(innerPadding) + .padding(top = 16.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(vertical = 16.dp), + text = stringResource(Res.string.permission_camera_request) + ) + BottomButton( + text = stringResource(Res.string.permission_camera_request), + onClick = { askForPermission.value = true } + ) + } + } + } + } + } +} + +@Composable +expect fun cameraPreview( + modifier: Modifier, + barcodeScanned: (String) -> Unit +) diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt similarity index 100% rename from shared/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt rename to scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt new file mode 100644 index 0000000..128337c --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt @@ -0,0 +1,43 @@ +package de.tillhub.scanengine.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import de.tillhub.scanengine.ui.theme.MagneticGrey +import de.tillhub.scanengine.ui.theme.OrbitalBlue +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +@Preview +internal fun BottomButton( + isEnable: Boolean = true, + text: String, + onClick: () -> Unit = {} +) { + Button( + enabled = isEnable, + modifier = Modifier + .fillMaxWidth() + .testTag("submitButton"), + shape = RectangleShape, + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = if (isEnable) OrbitalBlue else MagneticGrey + ) + ) { + Text( + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(vertical = 8.dp), + text = text + ) + } +} \ No newline at end of file diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt new file mode 100644 index 0000000..873cbb7 --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt @@ -0,0 +1,26 @@ +package de.tillhub.scanengine.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo + +/** + * This size represents minimum screen size to be considered as tablet + * It also corresponds to values-w600dp folder in resources + */ +private const val TABLET_SCREEN_SIZE = 600 + +@Composable +internal fun isTablet(): Boolean { + val configuration = LocalDensity.current + val screenWidthDp = with(configuration) { + LocalWindowInfo.current.containerSize.width / density + } + return screenWidthDp >= TABLET_SCREEN_SIZE +} + +@Composable +internal fun getModifierBasedOnDeviceType(isTablet: Modifier, isMobile: Modifier): Modifier { + return if (isTablet()) isTablet else isMobile +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt new file mode 100644 index 0000000..d6df8e1 --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt @@ -0,0 +1,57 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package de.tillhub.scanengine.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +internal fun Toolbar( + title: String, + onClick: () -> Unit +) { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors() + .copy(containerColor = Color.White), + title = { + Text( + title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.testTag("toolbarTitle") + ) + }, + navigationIcon = { + IconButton( + onClick = onClick, + modifier = Modifier.testTag("toolbarIcon") + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "close" + ) + } + } + ) + HorizontalDivider() + } +} + +@Preview +@Composable internal fun ToolbarPreview() { + Toolbar("Title example") {} +} \ No newline at end of file diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt new file mode 100644 index 0000000..fe32f39 --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt @@ -0,0 +1,12 @@ +package de.tillhub.scanengine.ui.theme + +import androidx.compose.ui.graphics.Color + +val HintGray = Color(0xFF666666) +val LunarGray = Color(0xFFE9EAEB) +val Tint = Color(0xFFFFFFFF) +val GalacticBlue = Color(0xAA232E3D) +val ExtraButtonTint = Color(0xFFECEFF0) +val OrbitalBlue = Color(0xFF279FF6) +val SoyuzGrey = Color(0xFF6E737A) +val MagneticGrey = Color(0xFFA7ABB1) diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt new file mode 100644 index 0000000..0fbf0e6 --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt @@ -0,0 +1,34 @@ +package de.tillhub.scanengine.ui.theme + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults.buttonElevation +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +internal fun buttonElevation() = buttonElevation( + defaultElevation = 3.dp, + pressedElevation = 1.dp, + disabledElevation = 1.dp, + hoveredElevation = 2.dp, + focusedElevation = 2.dp +) + +@Composable +internal fun textFieldTransparentColors() = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, +) + +internal val TabletScaffoldModifier = Modifier + .width(380.dp) + .height(600.dp) + .clip(RoundedCornerShape(8.dp)) diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt new file mode 100644 index 0000000..b4ba2bc --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt @@ -0,0 +1,14 @@ +package de.tillhub.scanengine.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +internal fun AppTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + typography = typography, + content = content + ) +} \ No newline at end of file diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt new file mode 100644 index 0000000..d0bab37 --- /dev/null +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt @@ -0,0 +1,99 @@ +package de.tillhub.scanengine.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +internal val typography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.Light, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontWeight = FontWeight.Light, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt similarity index 100% rename from shared/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt rename to scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt similarity index 72% rename from shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt rename to scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index aa5a09b..349b325 100644 --- a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -1,6 +1,8 @@ package de.tillhub.scanengine.camera -actual class CameraController { +import platform.UIKit.UIViewController + +actual class CameraController : UIViewController(null, null) { /** * Starts the camera session. */ diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt similarity index 100% rename from shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt rename to scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt new file mode 100644 index 0000000..dcdb2b0 --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt @@ -0,0 +1,50 @@ +package de.tillhub.scanengine.camera.contract + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.window.ComposeUIViewController +import de.tillhub.scanengine.camera.ui.CameraScreen +import de.tillhub.scanengine.data.ScannerEvent +import platform.UIKit.UIApplication +import platform.UIKit.UIViewController + +@Composable +actual fun rememberCameraScanLauncher( + onResult: (ScannerEvent) -> Unit +): CameraScanContract = remember { + object : CameraScanContract { + private var viewController: UIViewController? = null + + override fun launchCameraScanner(scanKey: String?) { + val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + + viewController = ComposeUIViewController { + CameraScreen( + onResult = { + onResult( + ScannerEvent.ScanResult( + value = it, + scanKey = scanKey + ) + ) + dismiss() + }, + onDismiss = { + onResult(ScannerEvent.Camera.Canceled) + dismiss() + } + ) + } + + viewController?.let { vc -> + rootVC.presentViewController(vc, animated = true, completion = null) + } + } + + private fun dismiss() { + viewController?.dismissViewControllerAnimated(true) { + viewController = null + } + } + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt similarity index 79% rename from shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt rename to scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index 36d256b..2d83a96 100644 --- a/shared/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -8,8 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable actual fun cameraPreview( modifier: Modifier, - scanKey: String?, - scannerEvents: MutableStateFlow + barcodeScanned: (String) -> Unit ) { } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 7ffeb90..1472d2a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,5 +16,5 @@ dependencyResolutionManagement { } rootProject.name = "Tillhub_Scan_Engine" -include(":shared") +include(":scan_engine") include(":sample") diff --git a/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt deleted file mode 100644 index 626e3d4..0000000 --- a/shared/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ /dev/null @@ -1,43 +0,0 @@ -package de.tillhub.scanengine.camera.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import de.tillhub.scanengine.camera.PermissionHandler -import de.tillhub.scanengine.camera.getPermissionHandler -import de.tillhub.scanengine.data.ScannerEvent -import kotlinx.coroutines.flow.MutableStateFlow -import org.jetbrains.compose.ui.tooling.preview.Preview - -@Preview -@Composable -fun CameraScreen( - modifier: Modifier = Modifier, - scanKey: String? = null, - scannerEvents: MutableStateFlow -) { - val permissions: PermissionHandler = getPermissionHandler() - - val hasPermission = remember { mutableStateOf(permissions.hasCameraPermission()) } - - if (hasPermission.value) { - cameraPreview(modifier, scanKey, scannerEvents) - } else { - permissions.RequestCameraPermission( - onGranted = { - hasPermission.value = true - }, - onDenied = { - // TODO - } - ) - } -} - -@Composable -expect fun cameraPreview( - modifier: Modifier, - scanKey: String?, - scannerEvents: MutableStateFlow -) From 369e1fa4d86266c79ded3fce3ea3999b052a4fb5 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 23 Jun 2025 14:26:54 +0200 Subject: [PATCH 03/19] [UNTIL-14107] added IOS implementation for qr scanning, not working yet --- .gitignore | 10 +- iosApp/Configuration/Config.xcconfig | 7 + iosApp/iosApp.xcodeproj/project.pbxproj | 379 ++++++++++++++++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 36 ++ .../AppIcon.appiconset/app-icon-1024.png | Bin 0 -> 67285 bytes iosApp/iosApp/Assets.xcassets/Contents.json | 6 + iosApp/iosApp/ContentView.swift | 21 + iosApp/iosApp/Info.plist | 12 + .../Preview Assets.xcassets/Contents.json | 6 + iosApp/iosApp/iOSApp.swift | 10 + sample/build.gradle.kts | 21 +- .../de/tillhub/scanengine/sample/App.kt | 4 +- .../tillhub/scanengine/sample/theme/Theme.kt | 29 +- scan_engine/build.gradle.kts | 16 +- .../camera/CameraController.android.kt | 5 - .../scanengine/camera/CameraController.kt | 2 - .../scanengine/camera/ui/CameraScreen.kt | 5 +- .../scanengine/camera/CameraController.ios.kt | 132 +++++- .../tillhub/scanengine/camera/CustomCamera.kt | 170 ++++++++ .../scanengine/camera/ui/CameraScreen.ios.kt | 30 ++ 21 files changed, 848 insertions(+), 64 deletions(-) create mode 100644 iosApp/Configuration/Config.xcconfig create mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png create mode 100644 iosApp/iosApp/Assets.xcassets/Contents.json create mode 100644 iosApp/iosApp/ContentView.swift create mode 100644 iosApp/iosApp/Info.plist create mode 100644 iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 iosApp/iosApp/iOSApp.swift create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt diff --git a/.gitignore b/.gitignore index 1996883..772158e 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,12 @@ out/ # CocoaPods Pods/ -*.xcworkspace \ No newline at end of file +*.xcworkspace + +# ============================ +# Xcode / iOS +# ============================ +iosApp/Pods/ +/iosApp/iosApp.xcodeproj/xcuserdata/ +/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/ +**/xcuserdata/ \ No newline at end of file diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig new file mode 100644 index 0000000..6d34af0 --- /dev/null +++ b/iosApp/Configuration/Config.xcconfig @@ -0,0 +1,7 @@ +TEAM_ID= + +PRODUCT_NAME=Tillhub_Scan_Engine +PRODUCT_BUNDLE_IDENTIFIER=de.tillhub.scanengine.sample.scan-engine$(TEAM_ID) + +CURRENT_PROJECT_VERSION=1 +MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bc7878b --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,379 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + B9DA97B12DC1472C00A4DA20 /* input-engine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = input-engine.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + B99700CA2DC9B8D800C7335B /* Exceptions for "iosApp" folder in "iosApp" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = B9DA97B02DC1472C00A4DA20 /* iosApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B9DA97B32DC1472C00A4DA20 /* iosApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + B99700CA2DC9B8D800C7335B /* Exceptions for "iosApp" folder in "iosApp" target */, + ); + path = iosApp; + sourceTree = ""; + }; + B9DA98002DC14AA900A4DA20 /* Configuration */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Configuration; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + B9DA97AE2DC1472C00A4DA20 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B9DA97A82DC1472C00A4DA20 = { + isa = PBXGroup; + children = ( + B9DA98002DC14AA900A4DA20 /* Configuration */, + B9DA97B32DC1472C00A4DA20 /* iosApp */, + B9DA97B22DC1472C00A4DA20 /* Products */, + ); + sourceTree = ""; + }; + B9DA97B22DC1472C00A4DA20 /* Products */ = { + isa = PBXGroup; + children = ( + B9DA97B12DC1472C00A4DA20 /* input-engine.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B9DA97B02DC1472C00A4DA20 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B9DA97BF2DC1472D00A4DA20 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + B9DA97F42DC1497100A4DA20 /* Compile Kotlin Framework */, + B9DA97AD2DC1472C00A4DA20 /* Sources */, + B9DA97AE2DC1472C00A4DA20 /* Frameworks */, + B9DA97AF2DC1472C00A4DA20 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + B9DA97B32DC1472C00A4DA20 /* iosApp */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = B9DA97B12DC1472C00A4DA20 /* input-engine.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B9DA97A92DC1472C00A4DA20 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + B9DA97B02DC1472C00A4DA20 = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = B9DA97AC2DC1472C00A4DA20 /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B9DA97A82DC1472C00A4DA20; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = B9DA97B22DC1472C00A4DA20 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B9DA97B02DC1472C00A4DA20 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B9DA97AF2DC1472C00A4DA20 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + B9DA97F42DC1497100A4DA20 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :sample:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B9DA97AD2DC1472C00A4DA20 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + B9DA97BD2DC1472D00A4DA20 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + B9DA97BE2DC1472D00A4DA20 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B9DA97C02DC1472D00A4DA20 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B9DA98002DC14AA900A4DA20 /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B9DA97C12DC1472D00A4DA20 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B9DA98002DC14AA900A4DA20 /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B9DA97AC2DC1472C00A4DA20 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B9DA97BD2DC1472D00A4DA20 /* Debug */, + B9DA97BE2DC1472D00A4DA20 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B9DA97BF2DC1472D00A4DA20 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B9DA97C02DC1472D00A4DA20 /* Debug */, + B9DA97C12DC1472D00A4DA20 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B9DA97A92DC1472C00A4DA20 /* Project object */; +} \ No newline at end of file diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4e8d485 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "app-icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..53fc536fb9ac5c1dbb27c7e1da13db3760070a11 GIT binary patch literal 67285 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 BXNCX( literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..3cd5c32 --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI +import ComposeApp + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var body: some View { + ComposeView() + .ignoresSafeArea(.keyboard) // Compose has own keyboard handler + } +} + + + diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..d43f27c --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,12 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + NSCameraUsageDescription + Camera permission is required for the app to work. + + diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..d83dca6 --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} \ No newline at end of file diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 3190928..220aa5a 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -14,22 +14,19 @@ kotlin { } } - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64() - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "ComposeApp" - isStatic = true - } - } + val xcfName = "ComposeApp" + iosX64 { binaries.framework { baseName = xcfName } } + iosArm64 { binaries.framework { baseName = xcfName } } + iosSimulatorArm64 { binaries.framework { baseName = xcfName } } sourceSets { androidMain.dependencies { implementation(libs.activity.compose) implementation(compose.preview) + + implementation(compose.uiTooling) + implementation(libs.lifecycle.runtime.ktx) } commonMain.dependencies { implementation(projects.scanEngine) @@ -40,9 +37,8 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(compose.ui) - implementation(compose.uiTooling) - implementation(libs.lifecycle.runtime.ktx) + } } } @@ -80,5 +76,4 @@ android { } dependencies { - } \ No newline at end of file diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt index a303634..8c764d6 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.tillhub.scanengine.ScanEngine import de.tillhub.scanengine.data.ScannerEvent -import de.tillhub.scanengine.sample.theme.Tillhub_Scan_EngineTheme +import de.tillhub.scanengine.sample.theme.ScanEngineTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -53,7 +53,7 @@ fun App() { } } - Tillhub_Scan_EngineTheme { + ScanEngineTheme { Box( contentAlignment = Alignment.TopCenter, modifier = Modifier diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt index 62ffd28..58571ca 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt @@ -1,15 +1,10 @@ package de.tillhub.scanengine.sample.theme -import android.app.Activity -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( primary = Purple80, @@ -21,34 +16,14 @@ private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ ) @Composable -fun Tillhub_Scan_EngineTheme( +fun ScanEngineTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, diff --git a/scan_engine/build.gradle.kts b/scan_engine/build.gradle.kts index a34e456..805bc21 100644 --- a/scan_engine/build.gradle.kts +++ b/scan_engine/build.gradle.kts @@ -25,17 +25,11 @@ kotlin { minSdk = 24 experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true } - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64() - ).forEach { - it.binaries.framework { - baseName = "scan_engine" - isStatic = true - } - } + + val xcfName = "scan_engine" + iosX64 { binaries.framework { baseName = xcfName } } + iosArm64 { binaries.framework { baseName = xcfName } } + iosSimulatorArm64 { binaries.framework { baseName = xcfName } } sourceSets { commonMain.dependencies { diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt index 7cc228b..136cf0f 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -30,7 +30,6 @@ actual class CameraController( BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build() ) ) { - private var scanKey: String? = null; private val analyzer: ImageAnalysis.Analyzer = QRImageAnalyzer( scanner = scanner, @@ -94,10 +93,6 @@ actual class CameraController( cameraProvider?.unbindAll() } - actual fun setScanKey(scanKey: String?) { - this.scanKey = scanKey - } - private fun createResolutionSelector(): ResolutionSelector { return ResolutionSelector.Builder() diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt index a0256e7..5aee3fb 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt @@ -10,6 +10,4 @@ expect class CameraController { * Stops the camera session. */ fun stopSession() - - fun setScanKey(scanKey: String?) } \ No newline at end of file diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index 665355b..be4c056 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -74,7 +74,10 @@ fun CameraScreen( ) { Text( style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(vertical = 16.dp), + modifier = Modifier.padding( + vertical = 16.dp, + horizontal = 8.dp + ), text = stringResource(Res.string.permission_camera_request) ) BottomButton( diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index 349b325..399120f 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -1,20 +1,148 @@ package de.tillhub.scanengine.camera +import kotlinx.atomicfu.atomic +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import platform.AVFoundation.AVCaptureConnection +import platform.AVFoundation.AVCaptureMetadataOutput +import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol +import platform.AVFoundation.AVCaptureOutput +import platform.AVFoundation.AVCaptureVideoOrientation +import platform.AVFoundation.AVMetadataMachineReadableCodeObject +import platform.AVFoundation.AVMetadataObjectTypeAztecCode +import platform.AVFoundation.AVMetadataObjectTypeCode128Code +import platform.AVFoundation.AVMetadataObjectTypeCode39Code +import platform.AVFoundation.AVMetadataObjectTypeCode39Mod43Code +import platform.AVFoundation.AVMetadataObjectTypeCode93Code +import platform.AVFoundation.AVMetadataObjectTypeDataMatrixCode +import platform.AVFoundation.AVMetadataObjectTypeEAN13Code +import platform.AVFoundation.AVMetadataObjectTypeEAN8Code +import platform.AVFoundation.AVMetadataObjectTypeITF14Code +import platform.AVFoundation.AVMetadataObjectTypePDF417Code +import platform.AVFoundation.AVMetadataObjectTypeQRCode +import platform.AVFoundation.AVMetadataObjectTypeUPCECode import platform.UIKit.UIViewController +import platform.darwin.NSObject +import platform.darwin.dispatch_get_main_queue + +actual class CameraController( + barcodeScanned: (String) -> Unit, +) : UIViewController(null, null) { + private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned) + + private val customCameraController = CustomCameraController() + private var metadataOutput = AVCaptureMetadataOutput() -actual class CameraController : UIViewController(null, null) { /** * Starts the camera session. */ actual fun startSession() { + setupScanner() + + customCameraController.startSession() } /** * Stops the camera session. */ actual fun stopSession() { + customCameraController.stopSession() + } + + override fun viewDidLoad() { + super.viewDidLoad() + setupCamera() + } + + fun getCameraPreviewLayer() = customCameraController.cameraPreviewLayer + + internal fun currentVideoOrientation(): AVCaptureVideoOrientation = + customCameraController.currentVideoOrientation() + + private fun setupCamera() { + customCameraController.setupSession() + customCameraController.setupPreviewLayer(view) + + if (customCameraController.captureSession?.canAddOutput(metadataOutput) == true) { + customCameraController.captureSession?.addOutput(metadataOutput) + } + + startSession() + + customCameraController.onError = { error -> + println("Camera Error: $error") + } + } + + @OptIn(ExperimentalForeignApi::class) + override fun viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + customCameraController.cameraPreviewLayer?.setFrame(view.bounds) + } + + private fun setupScanner() { + metadataOutput.setMetadataObjectsDelegate(analyzer, dispatch_get_main_queue()) + + metadataOutput.metadataObjectTypes += listOf( + AVMetadataObjectTypeQRCode!!, + AVMetadataObjectTypeEAN13Code!!, + AVMetadataObjectTypeEAN8Code!!, + AVMetadataObjectTypeCode128Code!!, + AVMetadataObjectTypeCode39Code!!, + AVMetadataObjectTypeCode93Code!!, + AVMetadataObjectTypeCode39Mod43Code!!, + AVMetadataObjectTypeITF14Code!!, + AVMetadataObjectTypePDF417Code!!, + AVMetadataObjectTypeAztecCode!!, + AVMetadataObjectTypeDataMatrixCode!!, + AVMetadataObjectTypeUPCECode!! + ) + } +} + +internal class QRImageAnalyzer( + private val onCodeScanned: (String) -> Unit, + private val debounceMs: Long = 1000L +) : NSObject(), AVCaptureMetadataOutputObjectsDelegateProtocol { + + private val isProcessing = atomic(false) + private val scope = CoroutineScope(Dispatchers.Main) + private var lastScannedCode: String? = null + private var debounceJob: Job? = null + + override fun captureOutput( + output: AVCaptureOutput, + didOutputMetadataObjects: List<*>, + fromConnection: AVCaptureConnection + ) { + if (isProcessing.value) return + + for (metadata in didOutputMetadataObjects) { + if (metadata !is AVMetadataMachineReadableCodeObject) continue + val scannedCode = metadata.stringValue ?: continue + if (scannedCode == lastScannedCode) continue + + processCode(scannedCode) + break + } } - actual fun setScanKey(scanKey: String?) { + private fun processCode(code: String) { + debounceJob?.cancel() + debounceJob = scope.launch { + if (isProcessing.compareAndSet(expect = false, update = true)) { + try { + lastScannedCode = code + onCodeScanned(code) + delay(debounceMs) + } finally { + isProcessing.value = false + } + } + } } } \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt new file mode 100644 index 0000000..fd3150f --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt @@ -0,0 +1,170 @@ +package de.tillhub.scanengine.camera + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVCapturePhotoCaptureDelegateProtocol +import platform.AVFoundation.AVCapturePhotoOutput +import platform.AVFoundation.AVCaptureSession +import platform.AVFoundation.AVCaptureVideoPreviewLayer +import platform.AVFoundation.AVCaptureSessionPresetPhoto +import platform.AVFoundation.AVCapturePhotoQualityPrioritizationBalanced +import platform.AVFoundation.AVCaptureDeviceDiscoverySession +import platform.AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera +import platform.AVFoundation.AVMediaTypeVideo +import platform.AVFoundation.AVCaptureDevicePositionUnspecified +import platform.AVFoundation.AVCaptureDevicePositionBack +import platform.AVFoundation.AVCaptureDeviceInput +import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill +import platform.AVFoundation.AVCaptureVideoOrientation +import platform.AVFoundation.AVCaptureVideoOrientationPortrait +import platform.AVFoundation.AVCaptureVideoOrientationPortraitUpsideDown +import platform.AVFoundation.AVCaptureVideoOrientationLandscapeRight +import platform.AVFoundation.AVCaptureVideoOrientationLandscapeLeft +import platform.AVFoundation.position +import platform.UIKit.UIDevice +import platform.UIKit.UIDeviceOrientation +import platform.UIKit.UIView +import platform.darwin.DISPATCH_QUEUE_PRIORITY_HIGH +import platform.darwin.NSObject +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_global_queue + +class CustomCameraController : NSObject(), AVCapturePhotoCaptureDelegateProtocol { + var captureSession: AVCaptureSession? = null + private var currentCamera: AVCaptureDevice? = null + private var photoOutput: AVCapturePhotoOutput? = null + var cameraPreviewLayer: AVCaptureVideoPreviewLayer? = null + + var onError: ((CameraException) -> Unit)? = null + + sealed class CameraException : Exception() { + class DeviceNotAvailable : CameraException() + class ConfigurationError(message: String) : CameraException() + } + + fun setupSession() { + try { + captureSession = AVCaptureSession() + captureSession?.beginConfiguration() + + + captureSession?.sessionPreset = AVCaptureSessionPresetPhoto + + if (!setupInputs()) { + throw CameraException.DeviceNotAvailable() + } + + setupPhotoOutput() + captureSession?.commitConfiguration() + } catch (e: CameraException) { + cleanupSession() + onError?.invoke(e) + } + } + + private fun setupPhotoOutput() { + photoOutput = AVCapturePhotoOutput() + photoOutput?.setHighResolutionCaptureEnabled(false) + + photoOutput?.setMaxPhotoQualityPrioritization( + AVCapturePhotoQualityPrioritizationBalanced + ) + + photoOutput?.setPreparedPhotoSettingsArray(emptyList(), completionHandler = { settings, error -> + if (error != null) { + onError?.invoke(CameraException.ConfigurationError(error.localizedDescription)) + } + }) + + if (captureSession?.canAddOutput(photoOutput!!) == true) { + captureSession?.addOutput(photoOutput!!) + } else { + throw CameraException.ConfigurationError("Cannot add photo output") + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun setupInputs(): Boolean { + val availableDevices = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( + listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), + AVMediaTypeVideo, + AVCaptureDevicePositionUnspecified + ).devices + + if (availableDevices.isEmpty()) return false + + val backCamera = availableDevices.find { + (it as AVCaptureDevice).position == AVCaptureDevicePositionBack + } as? AVCaptureDevice + + currentCamera = backCamera ?: return false + + try { + val input = AVCaptureDeviceInput.deviceInputWithDevice( + currentCamera!!, + null + ) ?: return false + + if (captureSession?.canAddInput(input) == true) { + captureSession?.addInput(input) + return true + } + } catch (e: Exception) { + throw CameraException.ConfigurationError(e.message ?: "Unknown error") + } + return false + } + + fun startSession() { + if (captureSession?.isRunning() == false) { + dispatch_async( + dispatch_get_global_queue( + DISPATCH_QUEUE_PRIORITY_HIGH.toLong(), + 0u + ) + ) { + captureSession?.startRunning() + } + } + } + + fun stopSession() { + if (captureSession?.isRunning() == true) { + captureSession?.stopRunning() + } + } + + fun cleanupSession() { + stopSession() + cameraPreviewLayer?.removeFromSuperlayer() + cameraPreviewLayer = null + captureSession = null + photoOutput = null + currentCamera = null + } + + @OptIn(ExperimentalForeignApi::class) + fun setupPreviewLayer(view: UIView) { + captureSession?.let { session -> + val newPreviewLayer = AVCaptureVideoPreviewLayer(session = session).apply { + videoGravity = AVLayerVideoGravityResizeAspectFill + setFrame(view.bounds) + connection?.videoOrientation = currentVideoOrientation() + } + + view.layer.addSublayer(newPreviewLayer) + cameraPreviewLayer = newPreviewLayer + } + } + + fun currentVideoOrientation(): AVCaptureVideoOrientation { + val orientation = UIDevice.currentDevice.orientation + return when (orientation) { + UIDeviceOrientation.UIDeviceOrientationPortrait -> AVCaptureVideoOrientationPortrait + UIDeviceOrientation.UIDeviceOrientationPortraitUpsideDown -> AVCaptureVideoOrientationPortraitUpsideDown + UIDeviceOrientation.UIDeviceOrientationLandscapeLeft -> AVCaptureVideoOrientationLandscapeRight + UIDeviceOrientation.UIDeviceOrientationLandscapeRight -> AVCaptureVideoOrientationLandscapeLeft + else -> AVCaptureVideoOrientationPortrait + } + } +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index 2d83a96..a3d39e3 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -1,14 +1,44 @@ package de.tillhub.scanengine.camera.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitViewController +import de.tillhub.scanengine.camera.CameraController import de.tillhub.scanengine.data.ScannerEvent import kotlinx.coroutines.flow.MutableStateFlow +import platform.Foundation.NSNotificationCenter +import platform.UIKit.UIDeviceOrientationDidChangeNotification @Composable actual fun cameraPreview( modifier: Modifier, barcodeScanned: (String) -> Unit ) { + val cameraController = remember { + CameraController(barcodeScanned) + } + DisposableEffect(Unit) { + val notificationCenter = NSNotificationCenter.defaultCenter + val observer = notificationCenter.addObserverForName( + UIDeviceOrientationDidChangeNotification, + null, + null + ) { _ -> + cameraController.getCameraPreviewLayer()?.connection?.videoOrientation = + cameraController.currentVideoOrientation() + } + + onDispose { + notificationCenter.removeObserver(observer) + } + } + + UIKitViewController( + factory = { cameraController }, + modifier = modifier, + ) } \ No newline at end of file From 963d966d72c6e755608b03e71ccc452fee31324e Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 24 Jun 2025 09:57:23 +0200 Subject: [PATCH 04/19] [UNTIL-14107] test commit, after ios camera implementation --- iosApp/Configuration/Config.xcconfig | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 12 +++-- iosApp/iosApp/Info.plist | 2 - .../scanengine/camera/CameraController.ios.kt | 46 +++++++++---------- .../tillhub/scanengine/camera/CustomCamera.kt | 3 +- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 6d34af0..4604748 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,7 +1,7 @@ TEAM_ID= PRODUCT_NAME=Tillhub_Scan_Engine -PRODUCT_BUNDLE_IDENTIFIER=de.tillhub.scanengine.sample.scan-engine$(TEAM_ID) +PRODUCT_BUNDLE_IDENTIFIER=de.tillhub.scanengine.sample$(TEAM_ID) CURRENT_PROJECT_VERSION=1 MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index bc7878b..9dc4402 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXFileReference section */ - B9DA97B12DC1472C00A4DA20 /* input-engine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = input-engine.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B9DA97B12DC1472C00A4DA20 /* Tillhub_Scan_Engine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tillhub_Scan_Engine.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -59,7 +59,7 @@ B9DA97B22DC1472C00A4DA20 /* Products */ = { isa = PBXGroup; children = ( - B9DA97B12DC1472C00A4DA20 /* input-engine.app */, + B9DA97B12DC1472C00A4DA20 /* Tillhub_Scan_Engine.app */, ); name = Products; sourceTree = ""; @@ -87,7 +87,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = B9DA97B12DC1472C00A4DA20 /* input-engine.app */; + productReference = B9DA97B12DC1472C00A4DA20 /* Tillhub_Scan_Engine.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -304,6 +304,7 @@ ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required for the app to work."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -313,6 +314,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = de.tillhub.scanengine.sample; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -337,6 +339,7 @@ ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required for the app to work."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -346,6 +349,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = de.tillhub.scanengine.sample; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -376,4 +380,4 @@ /* End XCConfigurationList section */ }; rootObject = B9DA97A92DC1472C00A4DA20 /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index d43f27c..2f44658 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,8 +4,6 @@ CADisableMinimumFrameDurationOnPhone - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) NSCameraUsageDescription Camera permission is required for the app to work. diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index 399120f..57381ba 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -41,9 +41,8 @@ actual class CameraController( * Starts the camera session. */ actual fun startSession() { - setupScanner() - customCameraController.startSession() + setupScanner() } /** @@ -87,20 +86,20 @@ actual class CameraController( private fun setupScanner() { metadataOutput.setMetadataObjectsDelegate(analyzer, dispatch_get_main_queue()) - metadataOutput.metadataObjectTypes += listOf( - AVMetadataObjectTypeQRCode!!, - AVMetadataObjectTypeEAN13Code!!, - AVMetadataObjectTypeEAN8Code!!, - AVMetadataObjectTypeCode128Code!!, - AVMetadataObjectTypeCode39Code!!, - AVMetadataObjectTypeCode93Code!!, - AVMetadataObjectTypeCode39Mod43Code!!, - AVMetadataObjectTypeITF14Code!!, - AVMetadataObjectTypePDF417Code!!, - AVMetadataObjectTypeAztecCode!!, - AVMetadataObjectTypeDataMatrixCode!!, - AVMetadataObjectTypeUPCECode!! - ) +// metadataOutput.metadataObjectTypes += listOf( +// AVMetadataObjectTypeQRCode!!, +// AVMetadataObjectTypeEAN13Code!!, +// AVMetadataObjectTypeEAN8Code!!, +// AVMetadataObjectTypeCode128Code!!, +// AVMetadataObjectTypeCode39Code!!, +// AVMetadataObjectTypeCode93Code!!, +// AVMetadataObjectTypeCode39Mod43Code!!, +// AVMetadataObjectTypeITF14Code!!, +// AVMetadataObjectTypePDF417Code!!, +// AVMetadataObjectTypeAztecCode!!, +// AVMetadataObjectTypeDataMatrixCode!!, +// AVMetadataObjectTypeUPCECode!! +// ) } } @@ -121,13 +120,14 @@ internal class QRImageAnalyzer( ) { if (isProcessing.value) return - for (metadata in didOutputMetadataObjects) { - if (metadata !is AVMetadataMachineReadableCodeObject) continue - val scannedCode = metadata.stringValue ?: continue - if (scannedCode == lastScannedCode) continue - - processCode(scannedCode) - break + didOutputMetadataObjects.firstOrNull { + it is AVMetadataMachineReadableCodeObject && + !it.stringValue.isNullOrEmpty() && + it.stringValue != lastScannedCode + }?.let { scannedCode -> + (scannedCode as AVMetadataMachineReadableCodeObject).stringValue?.let { + processCode(it) + } } } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt index fd3150f..03214fd 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt @@ -11,7 +11,6 @@ import platform.AVFoundation.AVCapturePhotoQualityPrioritizationBalanced import platform.AVFoundation.AVCaptureDeviceDiscoverySession import platform.AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera import platform.AVFoundation.AVMediaTypeVideo -import platform.AVFoundation.AVCaptureDevicePositionUnspecified import platform.AVFoundation.AVCaptureDevicePositionBack import platform.AVFoundation.AVCaptureDeviceInput import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill @@ -88,7 +87,7 @@ class CustomCameraController : NSObject(), AVCapturePhotoCaptureDelegateProtocol val availableDevices = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), AVMediaTypeVideo, - AVCaptureDevicePositionUnspecified + AVCaptureDevicePositionBack ).devices if (availableDevices.isEmpty()) return false From a7956caca08e40ff4a43bafd6f8d4494cdd324c1 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 24 Jun 2025 13:40:34 +0200 Subject: [PATCH 05/19] [UNTIL-14107] code cleanup --- .../scanengine/camera/CameraController.ios.kt | 29 ++-- .../{CustomCamera.kt => CameraWrapper.kt} | 134 +++++++----------- .../scanengine/camera/ui/CameraScreen.ios.kt | 2 +- 3 files changed, 66 insertions(+), 99 deletions(-) rename scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/{CustomCamera.kt => CameraWrapper.kt} (77%) diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index 57381ba..e4a983c 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -30,18 +30,17 @@ import platform.darwin.NSObject import platform.darwin.dispatch_get_main_queue actual class CameraController( + private val cameraWrapper: CameraWrapper = CameraWrapper(), + private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput(), barcodeScanned: (String) -> Unit, ) : UIViewController(null, null) { private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned) - private val customCameraController = CustomCameraController() - private var metadataOutput = AVCaptureMetadataOutput() - /** * Starts the camera session. */ actual fun startSession() { - customCameraController.startSession() + cameraWrapper.startSession() setupScanner() } @@ -49,7 +48,7 @@ actual class CameraController( * Stops the camera session. */ actual fun stopSession() { - customCameraController.stopSession() + cameraWrapper.stopSession() } override fun viewDidLoad() { @@ -57,30 +56,30 @@ actual class CameraController( setupCamera() } - fun getCameraPreviewLayer() = customCameraController.cameraPreviewLayer + fun getCameraPreviewLayer() = cameraWrapper.cameraPreviewLayer internal fun currentVideoOrientation(): AVCaptureVideoOrientation = - customCameraController.currentVideoOrientation() + cameraWrapper.currentVideoOrientation() private fun setupCamera() { - customCameraController.setupSession() - customCameraController.setupPreviewLayer(view) + cameraWrapper.setupSession() + cameraWrapper.setupPreviewLayer(view) - if (customCameraController.captureSession?.canAddOutput(metadataOutput) == true) { - customCameraController.captureSession?.addOutput(metadataOutput) + if (cameraWrapper.captureSession?.canAddOutput(metadataOutput) == true) { + cameraWrapper.captureSession?.addOutput(metadataOutput) } - startSession() - - customCameraController.onError = { error -> + cameraWrapper.onError = { error -> println("Camera Error: $error") } + + startSession() } @OptIn(ExperimentalForeignApi::class) override fun viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - customCameraController.cameraPreviewLayer?.setFrame(view.bounds) + cameraWrapper.cameraPreviewLayer?.setFrame(view.bounds) } private fun setupScanner() { diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt similarity index 77% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt rename to scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt index 03214fd..aa034f5 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CustomCamera.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt @@ -2,23 +2,20 @@ package de.tillhub.scanengine.camera import kotlinx.cinterop.ExperimentalForeignApi import platform.AVFoundation.AVCaptureDevice -import platform.AVFoundation.AVCapturePhotoCaptureDelegateProtocol -import platform.AVFoundation.AVCapturePhotoOutput -import platform.AVFoundation.AVCaptureSession -import platform.AVFoundation.AVCaptureVideoPreviewLayer -import platform.AVFoundation.AVCaptureSessionPresetPhoto -import platform.AVFoundation.AVCapturePhotoQualityPrioritizationBalanced import platform.AVFoundation.AVCaptureDeviceDiscoverySession -import platform.AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera -import platform.AVFoundation.AVMediaTypeVideo -import platform.AVFoundation.AVCaptureDevicePositionBack import platform.AVFoundation.AVCaptureDeviceInput -import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill +import platform.AVFoundation.AVCaptureDevicePositionBack +import platform.AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera +import platform.AVFoundation.AVCaptureSession +import platform.AVFoundation.AVCaptureSessionPresetPhoto import platform.AVFoundation.AVCaptureVideoOrientation +import platform.AVFoundation.AVCaptureVideoOrientationLandscapeLeft +import platform.AVFoundation.AVCaptureVideoOrientationLandscapeRight import platform.AVFoundation.AVCaptureVideoOrientationPortrait import platform.AVFoundation.AVCaptureVideoOrientationPortraitUpsideDown -import platform.AVFoundation.AVCaptureVideoOrientationLandscapeRight -import platform.AVFoundation.AVCaptureVideoOrientationLandscapeLeft +import platform.AVFoundation.AVCaptureVideoPreviewLayer +import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill +import platform.AVFoundation.AVMediaTypeVideo import platform.AVFoundation.position import platform.UIKit.UIDevice import platform.UIKit.UIDeviceOrientation @@ -28,10 +25,9 @@ import platform.darwin.NSObject import platform.darwin.dispatch_async import platform.darwin.dispatch_get_global_queue -class CustomCameraController : NSObject(), AVCapturePhotoCaptureDelegateProtocol { - var captureSession: AVCaptureSession? = null +class CameraWrapper: NSObject() { private var currentCamera: AVCaptureDevice? = null - private var photoOutput: AVCapturePhotoOutput? = null + var captureSession: AVCaptureSession? = null var cameraPreviewLayer: AVCaptureVideoPreviewLayer? = null var onError: ((CameraException) -> Unit)? = null @@ -53,7 +49,6 @@ class CustomCameraController : NSObject(), AVCapturePhotoCaptureDelegateProtocol throw CameraException.DeviceNotAvailable() } - setupPhotoOutput() captureSession?.commitConfiguration() } catch (e: CameraException) { cleanupSession() @@ -61,27 +56,53 @@ class CustomCameraController : NSObject(), AVCapturePhotoCaptureDelegateProtocol } } - private fun setupPhotoOutput() { - photoOutput = AVCapturePhotoOutput() - photoOutput?.setHighResolutionCaptureEnabled(false) + fun startSession() { + if (captureSession?.isRunning() == false) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.toLong(), 0u)) { + captureSession?.startRunning() + } + } + } - photoOutput?.setMaxPhotoQualityPrioritization( - AVCapturePhotoQualityPrioritizationBalanced - ) + fun stopSession() { + if (captureSession?.isRunning() == true) { + captureSession?.stopRunning() + } + } - photoOutput?.setPreparedPhotoSettingsArray(emptyList(), completionHandler = { settings, error -> - if (error != null) { - onError?.invoke(CameraException.ConfigurationError(error.localizedDescription)) + @OptIn(ExperimentalForeignApi::class) + fun setupPreviewLayer(view: UIView) { + captureSession?.let { session -> + val newPreviewLayer = AVCaptureVideoPreviewLayer(session = session).apply { + videoGravity = AVLayerVideoGravityResizeAspectFill + setFrame(view.bounds) + connection?.videoOrientation = currentVideoOrientation() } - }) - if (captureSession?.canAddOutput(photoOutput!!) == true) { - captureSession?.addOutput(photoOutput!!) - } else { - throw CameraException.ConfigurationError("Cannot add photo output") + view.layer.addSublayer(newPreviewLayer) + cameraPreviewLayer = newPreviewLayer + } + } + + fun currentVideoOrientation(): AVCaptureVideoOrientation { + val orientation = UIDevice.currentDevice.orientation + return when (orientation) { + UIDeviceOrientation.UIDeviceOrientationPortrait -> AVCaptureVideoOrientationPortrait + UIDeviceOrientation.UIDeviceOrientationPortraitUpsideDown -> AVCaptureVideoOrientationPortraitUpsideDown + UIDeviceOrientation.UIDeviceOrientationLandscapeLeft -> AVCaptureVideoOrientationLandscapeRight + UIDeviceOrientation.UIDeviceOrientationLandscapeRight -> AVCaptureVideoOrientationLandscapeLeft + else -> AVCaptureVideoOrientationPortrait } } + private fun cleanupSession() { + stopSession() + cameraPreviewLayer?.removeFromSuperlayer() + cameraPreviewLayer = null + currentCamera = null + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") @OptIn(ExperimentalForeignApi::class) private fun setupInputs(): Boolean { val availableDevices = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( @@ -113,57 +134,4 @@ class CustomCameraController : NSObject(), AVCapturePhotoCaptureDelegateProtocol } return false } - - fun startSession() { - if (captureSession?.isRunning() == false) { - dispatch_async( - dispatch_get_global_queue( - DISPATCH_QUEUE_PRIORITY_HIGH.toLong(), - 0u - ) - ) { - captureSession?.startRunning() - } - } - } - - fun stopSession() { - if (captureSession?.isRunning() == true) { - captureSession?.stopRunning() - } - } - - fun cleanupSession() { - stopSession() - cameraPreviewLayer?.removeFromSuperlayer() - cameraPreviewLayer = null - captureSession = null - photoOutput = null - currentCamera = null - } - - @OptIn(ExperimentalForeignApi::class) - fun setupPreviewLayer(view: UIView) { - captureSession?.let { session -> - val newPreviewLayer = AVCaptureVideoPreviewLayer(session = session).apply { - videoGravity = AVLayerVideoGravityResizeAspectFill - setFrame(view.bounds) - connection?.videoOrientation = currentVideoOrientation() - } - - view.layer.addSublayer(newPreviewLayer) - cameraPreviewLayer = newPreviewLayer - } - } - - fun currentVideoOrientation(): AVCaptureVideoOrientation { - val orientation = UIDevice.currentDevice.orientation - return when (orientation) { - UIDeviceOrientation.UIDeviceOrientationPortrait -> AVCaptureVideoOrientationPortrait - UIDeviceOrientation.UIDeviceOrientationPortraitUpsideDown -> AVCaptureVideoOrientationPortraitUpsideDown - UIDeviceOrientation.UIDeviceOrientationLandscapeLeft -> AVCaptureVideoOrientationLandscapeRight - UIDeviceOrientation.UIDeviceOrientationLandscapeRight -> AVCaptureVideoOrientationLandscapeLeft - else -> AVCaptureVideoOrientationPortrait - } - } } \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index a3d39e3..d1057b6 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -18,7 +18,7 @@ actual fun cameraPreview( barcodeScanned: (String) -> Unit ) { val cameraController = remember { - CameraController(barcodeScanned) + CameraController(barcodeScanned = barcodeScanned) } DisposableEffect(Unit) { From 57d80c43724e801cf1a6b76426cebf41135e8677 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 30 Jun 2025 13:21:25 +0200 Subject: [PATCH 06/19] [UNTIL-14107] added error display for issues with camera view --- .../camera/ui/CameraScreen.android.kt | 3 +- .../composeResources/values-de/strings.xml | 2 + .../composeResources/values/strings.xml | 2 + .../scanengine/camera/ui/CameraScreen.kt | 41 ++++++++++++++----- .../ui/components/TabletExtensions.kt | 26 ------------ .../scanengine/camera/CameraController.ios.kt | 14 ++++--- .../scanengine/camera/CameraWrapper.kt | 8 +++- .../scanengine/camera/ui/CameraScreen.ios.kt | 8 +++- 8 files changed, 58 insertions(+), 46 deletions(-) delete mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt index ffdd8bf..097bbb2 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt @@ -15,7 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable actual fun cameraPreview( modifier: Modifier, - barcodeScanned: (String) -> Unit + barcodeScanned: (String) -> Unit, + onCameraError: (String) -> Unit ) { val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current diff --git a/scan_engine/src/commonMain/composeResources/values-de/strings.xml b/scan_engine/src/commonMain/composeResources/values-de/strings.xml index 6b49ba3..61a5df0 100644 --- a/scan_engine/src/commonMain/composeResources/values-de/strings.xml +++ b/scan_engine/src/commonMain/composeResources/values-de/strings.xml @@ -7,6 +7,8 @@ Um den Scanner nutzen zu können, müssen Sie den Zugriff im nächsten Berechtigungsdialog gewähren.\n\nMöchten Sie fortfahren? Kameraberechtigungen + Es gab ein Problem bei der Anforderung einer Kamera-Schnittstelle auf Ihrem Gerät. + OK Laden Barcode-Kopplung diff --git a/scan_engine/src/commonMain/composeResources/values/strings.xml b/scan_engine/src/commonMain/composeResources/values/strings.xml index 89f88fa..d9fd1ce 100644 --- a/scan_engine/src/commonMain/composeResources/values/strings.xml +++ b/scan_engine/src/commonMain/composeResources/values/strings.xml @@ -8,6 +8,8 @@ In order to use barcode reader you need to grant access by accepting the next permission dialog.\n\nWould you like to continue? Request camera permission + There was an issue requesting a camera interface on your device. + OK Loading Barcode pairing diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index be4c056..08f8779 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -16,14 +16,15 @@ import de.tillhub.scanengine.camera.PermissionHandler import de.tillhub.scanengine.camera.getPermissionHandler import de.tillhub.scanengine.ui.components.BottomButton import de.tillhub.scanengine.ui.components.Toolbar -import de.tillhub.scanengine.ui.components.getModifierBasedOnDeviceType -import de.tillhub.scanengine.ui.theme.TabletScaffoldModifier import de.tillhub.scanengine.ui.theme.AppTheme import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import de.tillhub.scanengine.resources.Res +import de.tillhub.scanengine.resources.camera_error import de.tillhub.scanengine.resources.camera_title import de.tillhub.scanengine.resources.permission_camera_request +import de.tillhub.scanengine.resources.permission_required_message +import de.tillhub.scanengine.resources.permission_required_title @Preview @Composable @@ -35,30 +36,49 @@ fun CameraScreen( val hasPermission = remember { mutableStateOf(permissions.hasCameraPermission()) } val askForPermission = remember { mutableStateOf(false) } + val cameraError = remember { mutableStateOf(false) } AppTheme { Scaffold( - modifier = getModifierBasedOnDeviceType( - isTablet = TabletScaffoldModifier, - isMobile = Modifier - ), + modifier = Modifier, topBar = { Toolbar( title = if (hasPermission.value) { stringResource(Res.string.camera_title) } else { - stringResource(Res.string.camera_title) + stringResource(Res.string.permission_required_title) }, onClick = { onDismiss() } ) } ) { innerPadding -> when { + cameraError.value -> { + Column( + modifier = Modifier + .padding(innerPadding) + .padding(top = 16.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding( + vertical = 16.dp, + horizontal = 8.dp + ), + text = stringResource(Res.string.camera_error) + ) + } + } hasPermission.value -> cameraPreview( modifier = Modifier .padding(innerPadding) .padding(top = 16.dp), - barcodeScanned = onResult + barcodeScanned = onResult, + onCameraError = { error -> + cameraError.value = true + } ) askForPermission.value -> permissions.RequestCameraPermission( onGranted = { hasPermission.value = true }, @@ -78,7 +98,7 @@ fun CameraScreen( vertical = 16.dp, horizontal = 8.dp ), - text = stringResource(Res.string.permission_camera_request) + text = stringResource(Res.string.permission_required_message) ) BottomButton( text = stringResource(Res.string.permission_camera_request), @@ -94,5 +114,6 @@ fun CameraScreen( @Composable expect fun cameraPreview( modifier: Modifier, - barcodeScanned: (String) -> Unit + barcodeScanned: (String) -> Unit, + onCameraError: (String) -> Unit ) diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt deleted file mode 100644 index 873cbb7..0000000 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/TabletExtensions.kt +++ /dev/null @@ -1,26 +0,0 @@ -package de.tillhub.scanengine.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo - -/** - * This size represents minimum screen size to be considered as tablet - * It also corresponds to values-w600dp folder in resources - */ -private const val TABLET_SCREEN_SIZE = 600 - -@Composable -internal fun isTablet(): Boolean { - val configuration = LocalDensity.current - val screenWidthDp = with(configuration) { - LocalWindowInfo.current.containerSize.width / density - } - return screenWidthDp >= TABLET_SCREEN_SIZE -} - -@Composable -internal fun getModifierBasedOnDeviceType(isTablet: Modifier, isMobile: Modifier): Modifier { - return if (isTablet()) isTablet else isMobile -} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index e4a983c..96c592c 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -32,7 +32,8 @@ import platform.darwin.dispatch_get_main_queue actual class CameraController( private val cameraWrapper: CameraWrapper = CameraWrapper(), private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput(), - barcodeScanned: (String) -> Unit, + private val onCameraError: (String) -> Unit, + barcodeScanned: (String) -> Unit ) : UIViewController(null, null) { private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned) @@ -62,6 +63,11 @@ actual class CameraController( cameraWrapper.currentVideoOrientation() private fun setupCamera() { + cameraWrapper.onError = { error -> + println("Camera Error: $error") + onCameraError(error.message.orEmpty()) + } + cameraWrapper.setupSession() cameraWrapper.setupPreviewLayer(view) @@ -69,10 +75,6 @@ actual class CameraController( cameraWrapper.captureSession?.addOutput(metadataOutput) } - cameraWrapper.onError = { error -> - println("Camera Error: $error") - } - startSession() } @@ -85,6 +87,7 @@ actual class CameraController( private fun setupScanner() { metadataOutput.setMetadataObjectsDelegate(analyzer, dispatch_get_main_queue()) + if (cameraWrapper.captureSession?.isRunning() == true) { // metadataOutput.metadataObjectTypes += listOf( // AVMetadataObjectTypeQRCode!!, // AVMetadataObjectTypeEAN13Code!!, @@ -99,6 +102,7 @@ actual class CameraController( // AVMetadataObjectTypeDataMatrixCode!!, // AVMetadataObjectTypeUPCECode!! // ) + } } } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt index aa034f5..61a30cf 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt @@ -5,6 +5,7 @@ import platform.AVFoundation.AVCaptureDevice import platform.AVFoundation.AVCaptureDeviceDiscoverySession import platform.AVFoundation.AVCaptureDeviceInput import platform.AVFoundation.AVCaptureDevicePositionBack +import platform.AVFoundation.AVCaptureDevicePositionUnspecified import platform.AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera import platform.AVFoundation.AVCaptureSession import platform.AVFoundation.AVCaptureSessionPresetPhoto @@ -99,16 +100,17 @@ class CameraWrapper: NSObject() { stopSession() cameraPreviewLayer?.removeFromSuperlayer() cameraPreviewLayer = null + captureSession = null currentCamera = null } - @Suppress("TooGenericExceptionCaught", "SwallowedException") + @Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount") @OptIn(ExperimentalForeignApi::class) private fun setupInputs(): Boolean { val availableDevices = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), AVMediaTypeVideo, - AVCaptureDevicePositionBack + AVCaptureDevicePositionUnspecified ).devices if (availableDevices.isEmpty()) return false @@ -117,6 +119,8 @@ class CameraWrapper: NSObject() { (it as AVCaptureDevice).position == AVCaptureDevicePositionBack } as? AVCaptureDevice + //val backCamera = availableDevices.firstOrNull() as? AVCaptureDevice + currentCamera = backCamera ?: return false try { diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index d1057b6..de185b1 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -15,10 +15,14 @@ import platform.UIKit.UIDeviceOrientationDidChangeNotification @Composable actual fun cameraPreview( modifier: Modifier, - barcodeScanned: (String) -> Unit + barcodeScanned: (String) -> Unit, + onCameraError: (String) -> Unit ) { val cameraController = remember { - CameraController(barcodeScanned = barcodeScanned) + CameraController( + barcodeScanned = barcodeScanned, + onCameraError = onCameraError + ) } DisposableEffect(Unit) { From 62d951f6ab1d93b7061b04bb5c7b3e4f4902f5bc Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 1 Jul 2025 11:01:12 +0200 Subject: [PATCH 07/19] [UNTIL-14107] fixed issue with ios camera --- .../scanengine/camera/ui/CameraScreen.kt | 7 +-- .../scanengine/camera/CameraController.ios.kt | 58 +++++++++---------- .../scanengine/camera/CameraWrapper.kt | 10 ++-- .../scanengine/camera/ui/CameraScreen.ios.kt | 3 +- 4 files changed, 40 insertions(+), 38 deletions(-) diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index 08f8779..6104a26 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -62,7 +62,7 @@ fun CameraScreen( verticalArrangement = Arrangement.SpaceBetween ) { Text( - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding( vertical = 16.dp, horizontal = 8.dp @@ -88,15 +88,14 @@ fun CameraScreen( Column( modifier = Modifier .padding(innerPadding) - .padding(top = 16.dp) .fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { Text( - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding( vertical = 16.dp, - horizontal = 8.dp + horizontal = 16.dp ), text = stringResource(Res.string.permission_required_message) ) diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index 96c592c..d8a7ecc 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -37,12 +37,25 @@ actual class CameraController( ) : UIViewController(null, null) { private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned) + override fun viewDidLoad() { + super.viewDidLoad() + setupCamera() + } + + @OptIn(ExperimentalForeignApi::class) + override fun viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + cameraWrapper.cameraPreviewLayer?.setFrame(view.bounds) + setupScanner() + } + + fun getCameraPreviewLayer() = cameraWrapper.cameraPreviewLayer + /** * Starts the camera session. */ actual fun startSession() { cameraWrapper.startSession() - setupScanner() } /** @@ -52,13 +65,6 @@ actual class CameraController( cameraWrapper.stopSession() } - override fun viewDidLoad() { - super.viewDidLoad() - setupCamera() - } - - fun getCameraPreviewLayer() = cameraWrapper.cameraPreviewLayer - internal fun currentVideoOrientation(): AVCaptureVideoOrientation = cameraWrapper.currentVideoOrientation() @@ -78,30 +84,24 @@ actual class CameraController( startSession() } - @OptIn(ExperimentalForeignApi::class) - override fun viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - cameraWrapper.cameraPreviewLayer?.setFrame(view.bounds) - } - private fun setupScanner() { metadataOutput.setMetadataObjectsDelegate(analyzer, dispatch_get_main_queue()) if (cameraWrapper.captureSession?.isRunning() == true) { -// metadataOutput.metadataObjectTypes += listOf( -// AVMetadataObjectTypeQRCode!!, -// AVMetadataObjectTypeEAN13Code!!, -// AVMetadataObjectTypeEAN8Code!!, -// AVMetadataObjectTypeCode128Code!!, -// AVMetadataObjectTypeCode39Code!!, -// AVMetadataObjectTypeCode93Code!!, -// AVMetadataObjectTypeCode39Mod43Code!!, -// AVMetadataObjectTypeITF14Code!!, -// AVMetadataObjectTypePDF417Code!!, -// AVMetadataObjectTypeAztecCode!!, -// AVMetadataObjectTypeDataMatrixCode!!, -// AVMetadataObjectTypeUPCECode!! -// ) + metadataOutput.metadataObjectTypes += listOf( + AVMetadataObjectTypeQRCode!!, + AVMetadataObjectTypeEAN13Code!!, + AVMetadataObjectTypeEAN8Code!!, + AVMetadataObjectTypeCode128Code!!, + AVMetadataObjectTypeCode39Code!!, + AVMetadataObjectTypeCode93Code!!, + AVMetadataObjectTypeCode39Mod43Code!!, + AVMetadataObjectTypeITF14Code!!, + AVMetadataObjectTypePDF417Code!!, + AVMetadataObjectTypeAztecCode!!, + AVMetadataObjectTypeDataMatrixCode!!, + AVMetadataObjectTypeUPCECode!! + ) } } } @@ -148,4 +148,4 @@ internal class QRImageAnalyzer( } } } -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt index 61a30cf..16fe460 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt @@ -43,7 +43,6 @@ class CameraWrapper: NSObject() { captureSession = AVCaptureSession() captureSession?.beginConfiguration() - captureSession?.sessionPreset = AVCaptureSessionPresetPhoto if (!setupInputs()) { @@ -59,7 +58,12 @@ class CameraWrapper: NSObject() { fun startSession() { if (captureSession?.isRunning() == false) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.toLong(), 0u)) { + dispatch_async( + queue = dispatch_get_global_queue( + identifier = DISPATCH_QUEUE_PRIORITY_HIGH.toLong(), + flags = 0u + ) + ) { captureSession?.startRunning() } } @@ -119,8 +123,6 @@ class CameraWrapper: NSObject() { (it as AVCaptureDevice).position == AVCaptureDevicePositionBack } as? AVCaptureDevice - //val backCamera = availableDevices.firstOrNull() as? AVCaptureDevice - currentCamera = backCamera ?: return false try { diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index de185b1..6776201 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -1,5 +1,6 @@ package de.tillhub.scanengine.camera.ui +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -42,7 +43,7 @@ actual fun cameraPreview( } UIKitViewController( + modifier = Modifier.fillMaxSize(), factory = { cameraController }, - modifier = modifier, ) } \ No newline at end of file From e7bf236d3077ad1b5dfcf0eefb7a9ef4131ea09e Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 1 Jul 2025 13:56:04 +0200 Subject: [PATCH 08/19] [UNTIL-14107] Added comments to the IOS implementation --- .../camera/AndroidPermissionHandler.kt | 2 +- .../scanengine/camera/PermissionHandler.kt | 8 +- .../camera/contract/CameraScanContract.kt | 3 + .../scanengine/camera/ui/CameraScreen.kt | 2 +- .../scanengine/camera/CameraController.ios.kt | 121 ++++++++++++++++- .../scanengine/camera/CameraWrapper.kt | 127 +++++++++++++----- .../scanengine/camera/IosPermissionHandler.kt | 30 ++++- .../scanengine/camera/common/DispatchAsync.kt | 20 +++ .../camera/contract/CameraScanContract.ios.kt | 21 +++ .../scanengine/camera/ui/CameraScreen.ios.kt | 19 +++ 10 files changed, 314 insertions(+), 39 deletions(-) create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt index 38836e5..95d3983 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt @@ -22,7 +22,7 @@ class AndroidPermissionHandler( } @Composable - override fun RequestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { + override fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { isGranted -> diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt index f1cb705..2cfe962 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt @@ -2,6 +2,12 @@ package de.tillhub.scanengine.camera import androidx.compose.runtime.Composable +/** + * Interface for handling camera permission requests. + * + * This interface provides methods to check and request camera permission, + * crucial for features that require access to the device's camera. + */ interface PermissionHandler { /** * Checks if the camera permission is granted. @@ -17,7 +23,7 @@ interface PermissionHandler { * @param onDenied Callback invoked when permission is denied. */ @Composable - fun RequestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) + fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) } @Composable diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt index 6f80f5c..3e05d1d 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt @@ -3,6 +3,9 @@ package de.tillhub.scanengine.camera.contract import androidx.compose.runtime.Composable import de.tillhub.scanengine.data.ScannerEvent +/** + * Interface that can be used to scan barcodes with the device's camera. + */ interface CameraScanContract { fun launchCameraScanner(scanKey: String? = null) } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index 6104a26..30ca718 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -80,7 +80,7 @@ fun CameraScreen( cameraError.value = true } ) - askForPermission.value -> permissions.RequestCameraPermission( + askForPermission.value -> permissions.requestCameraPermission( onGranted = { hasPermission.value = true }, onDenied = { askForPermission.value = false } ) diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index d8a7ecc..8ff0e5f 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -1,5 +1,6 @@ package de.tillhub.scanengine.camera +import de.tillhub.scanengine.camera.common.dispatchAsync import kotlinx.atomicfu.atomic import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CoroutineScope @@ -29,6 +30,18 @@ import platform.UIKit.UIViewController import platform.darwin.NSObject import platform.darwin.dispatch_get_main_queue +/** + * iOS implementation of the CameraController. + * This class is responsible for managing the camera session, preview layer, and barcode scanning. + * + * @param cameraWrapper An instance of [CameraWrapper] to interact with the camera hardware. + * @param metadataOutput An instance of [AVCaptureMetadataOutput] to handle metadata from the camera, + * specifically for barcode scanning. + * @param onCameraError A lambda function that is invoked when a camera error occurs. + * It receives the error message as a [String]. + * @param barcodeScanned A lambda function that is invoked when a barcode is successfully scanned. + * It receives the scanned barcode value as a [String]. + */ actual class CameraController( private val cameraWrapper: CameraWrapper = CameraWrapper(), private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput(), @@ -37,19 +50,37 @@ actual class CameraController( ) : UIViewController(null, null) { private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned) + /** + * Called after the controller's view is loaded into memory. + * This method initializes the camera setup. + */ override fun viewDidLoad() { super.viewDidLoad() setupCamera() } + /** + * Called to notify the view controller that its view has just laid out its subviews. + * This method adjusts the camera preview layer's frame to match the view's bounds + * and sets up the scanner asynchronously. + */ @OptIn(ExperimentalForeignApi::class) override fun viewDidLayoutSubviews() { super.viewDidLayoutSubviews() cameraWrapper.cameraPreviewLayer?.setFrame(view.bounds) - setupScanner() + dispatchAsync { + setupScanner() + } } - fun getCameraPreviewLayer() = cameraWrapper.cameraPreviewLayer + /** + * Provides access to the camera preview layer. + * This layer is responsible for displaying the camera feed. + * + * @return The [platform.AVFoundation.AVCaptureVideoPreviewLayer] used for displaying the camera preview, + * or null if it's not available. + */ + internal fun getCameraPreviewLayer() = cameraWrapper.cameraPreviewLayer /** * Starts the camera session. @@ -65,9 +96,29 @@ actual class CameraController( cameraWrapper.stopSession() } + /** + * Retrieves the current video orientation from the camera wrapper. + * This is important for ensuring that the displayed video and scanned barcodes + * are correctly oriented according to the device's current orientation. + * + * @return The current [AVCaptureVideoOrientation] of the camera. + */ internal fun currentVideoOrientation(): AVCaptureVideoOrientation = cameraWrapper.currentVideoOrientation() + /** + * Sets up the camera by configuring the camera wrapper and metadata output. + * This function initializes the error handling for the camera, sets up the camera session + * and preview layer, adds the metadata output to the session if possible, and then starts + * the camera session. + * + * - The `onError` callback of the [cameraWrapper] is set to print the error and invoke + * the [onCameraError] lambda passed to the constructor. + * - [CameraWrapper.setupSession] is called to prepare the camera session. + * - [CameraWrapper.setupPreviewLayer] is called to link the camera preview to the view. + * - If the [cameraWrapper]'s capture session can accept the [metadataOutput], it is added. + * - Finally, [startSession] is called to begin capturing video. + */ private fun setupCamera() { cameraWrapper.onError = { error -> println("Camera Error: $error") @@ -84,6 +135,28 @@ actual class CameraController( startSession() } + /** + * Sets up the scanner by configuring the metadata output. + * This function sets the delegate for handling metadata objects to the [analyzer] + * and specifies the types of metadata objects (barcodes) to detect. + * The metadata object types are only added if the camera session is currently running. + * + * The delegate will receive callbacks on the main dispatch queue. + * + * Supported barcode types include: + * - QR Code + * - EAN-13 + * - EAN-8 + * - Code 128 + * - Code 39 + * - Code 93 + * - Code 39 Mod 43 + * - ITF14 + * - PDF417 + * - Aztec + * - Data Matrix + * - UPC-E + */ private fun setupScanner() { metadataOutput.setMetadataObjectsDelegate(analyzer, dispatch_get_main_queue()) @@ -106,6 +179,18 @@ actual class CameraController( } } +/** + * An internal class responsible for analyzing camera output for QR codes and other machine-readable codes. + * It implements [AVCaptureMetadataOutputObjectsDelegateProtocol] to receive metadata objects from the camera. + * + * This class includes a debouncing mechanism to prevent processing the same code multiple times in quick succession. + * + * @property onCodeScanned A lambda function that is invoked when a new code is successfully scanned and processed. + * It receives the scanned code value as a [String]. + * @property debounceMs The debounce time in milliseconds. If a new code is scanned within this time period + * after the last processed code, it will be ignored to prevent rapid firing of the + * [onCodeScanned] callback. Defaults to 1000 milliseconds (1 second). + */ internal class QRImageAnalyzer( private val onCodeScanned: (String) -> Unit, private val debounceMs: Long = 1000L @@ -116,6 +201,20 @@ internal class QRImageAnalyzer( private var lastScannedCode: String? = null private var debounceJob: Job? = null + /** + * Delegate method called when the capture output outputs new metadata objects. + * This method is the entry point for barcode scanning. It processes the detected + * metadata objects, filters for machine-readable codes, and ensures that the same code + * is not processed repeatedly in quick succession. + * + * If a new, valid barcode is detected and the analyzer is not currently processing, + * it calls [processCode] to handle the scanned code. + * + * @param output The capture output that produced the metadata objects. + * @param didOutputMetadataObjects An array of metadata objects. Each object in this array + * is an instance of a subclass of [platform.AVFoundation.AVMetadataObject]. + * @param fromConnection The capture connection from which the metadata objects originated. + */ override fun captureOutput( output: AVCaptureOutput, didOutputMetadataObjects: List<*>, @@ -134,6 +233,24 @@ internal class QRImageAnalyzer( } } + /** + * Processes a scanned code string. + * + * This function implements a debouncing mechanism to prevent rapid processing of the same code. + * When a new code is received: + * 1. Any existing debounce job is cancelled. + * 2. A new coroutine is launched. + * 3. It attempts to set the `isProcessing` flag to `true`. If successful: + * a. The `lastScannedCode` is updated to the new code. + * b. The `onCodeScanned` callback is invoked with the new code. + * c. A delay is introduced, equal to `debounceMs`. + * d. Finally, the `isProcessing` flag is reset to `false`. + * + * This ensures that `onCodeScanned` is not called too frequently if the camera detects the same + * code multiple times in quick succession. + * + * @param code The scanned code string to process. + */ private fun processCode(code: String) { debounceJob?.cancel() debounceJob = scope.launch { diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt index 16fe460..2ec4db2 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt @@ -1,5 +1,6 @@ package de.tillhub.scanengine.camera +import de.tillhub.scanengine.camera.common.dispatchAsync import kotlinx.cinterop.ExperimentalForeignApi import platform.AVFoundation.AVCaptureDevice import platform.AVFoundation.AVCaptureDeviceDiscoverySession @@ -21,11 +22,24 @@ import platform.AVFoundation.position import platform.UIKit.UIDevice import platform.UIKit.UIDeviceOrientation import platform.UIKit.UIView -import platform.darwin.DISPATCH_QUEUE_PRIORITY_HIGH import platform.darwin.NSObject -import platform.darwin.dispatch_async -import platform.darwin.dispatch_get_global_queue +/** + * A wrapper class for managing camera operations on iOS devices using AVFoundation. + * + * This class encapsulates the setup, control, and preview of the device camera. + * It provides functionalities to: + * - Initialize and configure an `AVCaptureSession`. + * - Start and stop the capture session. + * - Set up a preview layer to display the camera feed on a `UIView`. + * - Handle camera-related errors through a customizable `onError` callback. + * + * It primarily focuses on using the back-facing wide-angle camera. + * + * @property captureSession The active `AVCaptureSession` instance, if initialized. + * @property cameraPreviewLayer The `AVCaptureVideoPreviewLayer` used to display the camera feed, if set up. + * @property onError A callback function that is invoked when a [CameraException] occurs. + */ class CameraWrapper: NSObject() { private var currentCamera: AVCaptureDevice? = null var captureSession: AVCaptureSession? = null @@ -38,7 +52,15 @@ class CameraWrapper: NSObject() { class ConfigurationError(message: String) : CameraException() } - fun setupSession() { + /** + * This function initializes and configures the AVCaptureSession for camera operations. + * It sets the session preset to AVCaptureSessionPresetPhoto for high-quality still images. + * It then attempts to set up the camera inputs using the [setupInputs] method. + * If input setup fails, it throws a [CameraException.DeviceNotAvailable]. + * If any [CameraException] occurs during setup, the session is cleaned up using [cleanupSession], + * and the [onError] callback is invoked with the exception. + */ + internal fun setupSession() { try { captureSession = AVCaptureSession() captureSession?.beginConfiguration() @@ -56,27 +78,39 @@ class CameraWrapper: NSObject() { } } - fun startSession() { + /** + * Starts the camera capture session if it is not already running. + * The session is started asynchronously on a separate dispatch queue. + */ + internal fun startSession() { if (captureSession?.isRunning() == false) { - dispatch_async( - queue = dispatch_get_global_queue( - identifier = DISPATCH_QUEUE_PRIORITY_HIGH.toLong(), - flags = 0u - ) - ) { + dispatchAsync { captureSession?.startRunning() } } } - fun stopSession() { + /** + * Stops the camera capture session if it is currently running. + */ + internal fun stopSession() { if (captureSession?.isRunning() == true) { captureSession?.stopRunning() } } + /** + * Sets up the preview layer for displaying the camera feed. + * + * This function creates an `AVCaptureVideoPreviewLayer` from the current `captureSession` + * and adds it as a sublayer to the provided `UIView`. The preview layer is configured + * to fill the bounds of the view and maintain the aspect ratio of the video. + * The video orientation is set based on the current device orientation. + * + * @param view The `UIView` on which the camera preview will be displayed. + */ @OptIn(ExperimentalForeignApi::class) - fun setupPreviewLayer(view: UIView) { + internal fun setupPreviewLayer(view: UIView) { captureSession?.let { session -> val newPreviewLayer = AVCaptureVideoPreviewLayer(session = session).apply { videoGravity = AVLayerVideoGravityResizeAspectFill @@ -89,7 +123,20 @@ class CameraWrapper: NSObject() { } } - fun currentVideoOrientation(): AVCaptureVideoOrientation { + /** + * Determines the appropriate `AVCaptureVideoOrientation` based on the current device orientation. + * + * This function maps `UIDeviceOrientation` values to their corresponding `AVCaptureVideoOrientation` + * values. It handles portrait, portrait upside down, landscape left, and landscape right orientations. + * If the device orientation is unknown or face up/down, it defaults to portrait orientation. + * + * Note: `AVCaptureVideoOrientationLandscapeLeft` corresponds to `UIDeviceOrientationLandscapeRight` + * and vice-versa. This is because `AVCaptureVideoOrientation` refers to the orientation of the + * video buffer, while `UIDeviceOrientation` refers to the physical orientation of the device. + * + * @return The `AVCaptureVideoOrientation` that corresponds to the current device orientation. + */ + internal fun currentVideoOrientation(): AVCaptureVideoOrientation { val orientation = UIDevice.currentDevice.orientation return when (orientation) { UIDeviceOrientation.UIDeviceOrientationPortrait -> AVCaptureVideoOrientationPortrait @@ -100,6 +147,13 @@ class CameraWrapper: NSObject() { } } + /** + * Cleans up the camera session and related resources. + * + * This function stops the capture session, removes the preview layer from its superlayer, + * and nils out references to the preview layer, capture session, and current camera. + * This is typically called when the camera is no longer needed or in case of an error. + */ private fun cleanupSession() { stopSession() cameraPreviewLayer?.removeFromSuperlayer() @@ -108,7 +162,20 @@ class CameraWrapper: NSObject() { currentCamera = null } - @Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount") + /** + * Sets up the camera inputs for the `AVCaptureSession`. + * + * This function discovers available video capture devices, prioritizing the back-facing + * wide-angle camera. If a suitable camera is found, it creates an `AVCaptureDeviceInput` + * from it and attempts to add this input to the `captureSession`. + * + * @return `true` if the camera input was successfully set up and added to the session, + * `false` otherwise (e.g., if no suitable camera is found or if the input + * cannot be added to the session). + * @throws CameraException.ConfigurationError if an error occurs while creating the + * `AVCaptureDeviceInput`. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") @OptIn(ExperimentalForeignApi::class) private fun setupInputs(): Boolean { val availableDevices = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( @@ -117,26 +184,22 @@ class CameraWrapper: NSObject() { AVCaptureDevicePositionUnspecified ).devices - if (availableDevices.isEmpty()) return false - - val backCamera = availableDevices.find { + (availableDevices.find { (it as AVCaptureDevice).position == AVCaptureDevicePositionBack - } as? AVCaptureDevice - - currentCamera = backCamera ?: return false + } as? AVCaptureDevice)?.let { currentCamera -> + try { + val input = AVCaptureDeviceInput.deviceInputWithDevice( + currentCamera, + null + ) - try { - val input = AVCaptureDeviceInput.deviceInputWithDevice( - currentCamera!!, - null - ) ?: return false - - if (captureSession?.canAddInput(input) == true) { - captureSession?.addInput(input) - return true + if (input != null && captureSession?.canAddInput(input) == true) { + captureSession?.addInput(input) + return true + } + } catch (e: Exception) { + throw CameraException.ConfigurationError(e.message ?: "Unknown error") } - } catch (e: Exception) { - throw CameraException.ConfigurationError(e.message ?: "Unknown error") } return false } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt index 0d01d84..ea2ff35 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt @@ -8,14 +8,40 @@ import platform.AVFoundation.AVMediaTypeVideo import platform.AVFoundation.authorizationStatusForMediaType import platform.AVFoundation.requestAccessForMediaType -class IosPermissionHandler : PermissionHandler { +/** + * iOS implementation of [PermissionHandler] for managing camera permissions. + * + * This class utilizes the `AVFoundation` framework to check and request + * access to the device's camera. + */ +internal class IosPermissionHandler : PermissionHandler { + /** + * Checks if the app has been granted permission to access the camera. + * + * This function queries the current authorization status for video media type + * using `AVCaptureDevice.authorizationStatusForMediaType`. + * + * @return `true` if the camera permission is authorized, `false` otherwise. + */ override fun hasCameraPermission(): Boolean { val status = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) return status == AVAuthorizationStatusAuthorized } + /** + * Requests permission to access the camera. + * + * This function uses `AVCaptureDevice.requestAccessForMediaType` to prompt + * the user for camera access. The provided callbacks are invoked based on + * the user's response. + * + * This function is composable and should be called from within a Composable context. + * + * @param onGranted A lambda function to be executed if the camera permission is granted. + * @param onDenied A lambda function to be executed if the camera permission is denied. + */ @Composable - override fun RequestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { + override fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { AVCaptureDevice.requestAccessForMediaType( AVMediaTypeVideo ) { granted -> diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt new file mode 100644 index 0000000..4b94432 --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt @@ -0,0 +1,20 @@ +package de.tillhub.scanengine.camera.common + +import platform.darwin.DISPATCH_QUEUE_PRIORITY_HIGH +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_global_queue + +/** + * Dispatch a block on the main queue. + * @param block The block to dispatch. + * @param priority The priority of the dispatch queue. + */ +fun dispatchAsync(priority: Int = DISPATCH_QUEUE_PRIORITY_HIGH, block: () -> Unit) { + dispatch_async( + queue = dispatch_get_global_queue( + identifier = priority.toLong(), + flags = 0u + ), + block = block + ) +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt index dcdb2b0..fff9e42 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt @@ -8,6 +8,12 @@ import de.tillhub.scanengine.data.ScannerEvent import platform.UIKit.UIApplication import platform.UIKit.UIViewController +/** + * iOS implementation of a [CameraScanContract] using a [UIViewController] to present the camera + * preview. + * + * @param onResult a callback for the result of the camera scan. + */ @Composable actual fun rememberCameraScanLauncher( onResult: (ScannerEvent) -> Unit @@ -15,6 +21,15 @@ actual fun rememberCameraScanLauncher( object : CameraScanContract { private var viewController: UIViewController? = null + /** + * Launches the camera scanner. + * + * This function presents a [ComposeUIViewController] containing the [CameraScreen] + * composable. The [CameraScreen] handles the camera preview and barcode scanning. + * + * @param scanKey An optional key to identify the scan session. This key will be included + * in the [ScannerEvent.ScanResult] when a barcode is successfully scanned. + */ override fun launchCameraScanner(scanKey: String?) { val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return @@ -41,6 +56,12 @@ actual fun rememberCameraScanLauncher( } } + /** + * Dismisses the camera scanner view controller. + * + * This function dismisses the currently presented [UIViewController] that hosts the + * camera preview. It also nullifies the `viewController` reference after dismissal. + */ private fun dismiss() { viewController?.dismissViewControllerAnimated(true) { viewController = null diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index 6776201..def0f85 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -13,6 +13,25 @@ import kotlinx.coroutines.flow.MutableStateFlow import platform.Foundation.NSNotificationCenter import platform.UIKit.UIDeviceOrientationDidChangeNotification +/** + * Displays a camera preview for scanning barcodes. + * This composable function is specific to the iOS platform. + * + * It initializes a [CameraController] to manage camera operations and barcode scanning. + * A `DisposableEffect` is used to listen for device orientation changes and update the + * camera preview's orientation accordingly. This ensures the preview remains correctly + * oriented as the device is rotated. + * + * The camera preview itself is rendered using `UIKitViewController`, which allows + * embedding UIKit views (in this case, the camera preview layer from `CameraController`) + * within a Compose UI. + * + * @param modifier The modifier to be applied to the camera preview. + * @param barcodeScanned A lambda function that is invoked when a barcode is successfully scanned. + * It receives the scanned barcode string as a parameter. + * @param onCameraError A lambda function that is invoked if an error occurs during camera initialization + * or operation. It receives an error message string as a parameter. + */ @Composable actual fun cameraPreview( modifier: Modifier, From cb2ba808220646a1d980fa6f800e6fec090dc47d Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 2 Jul 2025 14:21:25 +0200 Subject: [PATCH 09/19] [UNTIL-14107] reworked library building to be in line with input engine --- .gitignore | 1 + build.gradle.kts | 20 +++++- buildSrc/build.gradle.kts | 3 + buildSrc/settings.gradle.kts | 6 ++ buildSrc/src/main/kotlin/Configs.kt | 9 +++ gradle/libs.versions.toml | 3 + iosApp/iosApp.xcodeproj/project.pbxproj | 6 +- scan_engine/build.gradle.kts | 46 +++++++----- .../camera/AndroidPermissionHandler.kt | 31 ++++++++ .../camera/CameraController.android.kt | 72 +++++++++++++++++-- .../contract/CameraScanContract.android.kt | 20 ++++++ .../camera/ui/CameraScreen.android.kt | 21 +++++- .../de/tillhub/scanengine/CameraScanner.kt | 19 +++++ .../de/tillhub/scanengine/ScanEngine.kt | 57 +++++++++++++-- .../scanengine/camera/DefaultCameraScanner.kt | 19 ++--- .../de/tillhub/scanengine/ExampleTest.kt | 11 +++ .../camera/contract/CameraScanContract.ios.kt | 2 + 17 files changed, 301 insertions(+), 45 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/settings.gradle.kts create mode 100644 buildSrc/src/main/kotlin/Configs.kt create mode 100644 scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt diff --git a/.gitignore b/.gitignore index 772158e..432f9a2 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ out/ /build /scan_engine/build /sample/build +/buildSrc/build # Local configuration file (sdk path, etc) /local.properties diff --git a/build.gradle.kts b/build.gradle.kts index e670925..bdff3ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,27 @@ plugins { - //trick: for the same plugin versions in all sub-modules alias(libs.plugins.android.library).apply(false) alias(libs.plugins.kotlinMultiplatform).apply(false) - alias(libs.plugins.androidKotlinMultiplatformLibrary).apply(false) alias(libs.plugins.compose.compiler).apply(false) alias(libs.plugins.compose).apply(false) alias(libs.plugins.atomicfu).apply(false) alias(libs.plugins.android.application).apply(false) alias(libs.plugins.kotlin.android).apply(false) + alias(libs.plugins.spotless).apply(false) } + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure { + kotlin { + target("**/*.kt") + targetExclude("${layout.buildDirectory}/**/*.kt") + + ktlint() + } + + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + } +} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..bc0172f --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + `kotlin-dsl` +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000..c7c3c5d --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,6 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} diff --git a/buildSrc/src/main/kotlin/Configs.kt b/buildSrc/src/main/kotlin/Configs.kt new file mode 100644 index 0000000..321d4c7 --- /dev/null +++ b/buildSrc/src/main/kotlin/Configs.kt @@ -0,0 +1,9 @@ +import org.gradle.api.JavaVersion + +object Configs { + const val APPLICATION_ID = "de.tillhub.scanengine" + const val COMPILE_SDK = 35 + const val MIN_SDK = 24 + const val VERSION_NAME = "2.0.0" + val JAVA_VERSION = JavaVersion.VERSION_17 +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28beeaf..4980f7c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,8 @@ kotlinCoroutines = "1.10.2" lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" +spotless = "6.21.0" + [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -44,6 +46,7 @@ compose = { id = "org.jetbrains.compose", version.ref = "compose" } atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } [bundles] core = [ diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 9dc4402..562cf42 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -296,7 +296,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 8GL97FWG4R; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -315,6 +315,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = de.tillhub.scanengine.sample; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -331,7 +332,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 8GL97FWG4R; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -350,6 +351,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = de.tillhub.scanengine.sample; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/scan_engine/build.gradle.kts b/scan_engine/build.gradle.kts index 805bc21..f67d95c 100644 --- a/scan_engine/build.gradle.kts +++ b/scan_engine/build.gradle.kts @@ -1,29 +1,22 @@ -import com.android.build.api.dsl.androidLibrary +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree plugins { + alias(libs.plugins.android.library) alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) } kotlin { - targets.all { - compilations.all { - compileTaskProvider.configure { - compilerOptions { - // removes warnings for expect/actual classes - freeCompilerArgs.add("-Xexpect-actual-classes") - } - } - } + compilerOptions { + // removes warnings for expect/actual classes + freeCompilerArgs.add("-Xexpect-actual-classes") } - androidLibrary { - namespace = "de.tillhub.scanengine" - compileSdk = 35 - minSdk = 24 - experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) } val xcfName = "scan_engine" @@ -56,6 +49,27 @@ kotlin { } } +android { + namespace = Configs.APPLICATION_ID + compileSdk = Configs.COMPILE_SDK + + experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true + + defaultConfig { + minSdk = Configs.MIN_SDK + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + compileOptions { + sourceCompatibility = Configs.JAVA_VERSION + targetCompatibility = Configs.JAVA_VERSION + } +} + compose.resources { packageOfResClass = "de.tillhub.scanengine.resources" generateResClass = auto diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt index 95d3983..6c45cd0 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt @@ -11,9 +11,19 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat +/** + * Android-specific implementation of [PermissionHandler] for managing camera permissions. + * + * @param context The application context used to check and request permissions. + */ class AndroidPermissionHandler( private val context: Context ) : PermissionHandler { + /** + * Checks if the app has been granted camera permission. + * + * @return `true` if camera permission is granted, `false` otherwise. + */ override fun hasCameraPermission(): Boolean { return ContextCompat.checkSelfPermission( context, @@ -21,6 +31,17 @@ class AndroidPermissionHandler( ) == PackageManager.PERMISSION_GRANTED } + /** + * Composable function to request camera permission. + * + * This function checks the current camera permission status. + * If permission is already granted, the `onGranted` callback is invoked immediately. + * If permission is denied, it launches a system permission request dialog. + * The result of the dialog (granted or denied) will trigger the corresponding callback. + * + * @param onGranted Callback to be invoked if the camera permission is granted. + * @param onDenied Callback to be invoked if the camera permission is denied. + */ @Composable override fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { val launcher = rememberLauncherForActivityResult( @@ -51,6 +72,16 @@ class AndroidPermissionHandler( } } +/** + * Composable function that provides an instance of [PermissionHandler]. + * + * This function is used to obtain a platform-specific implementation of [PermissionHandler]. + * On Android, it returns an [AndroidPermissionHandler] initialized with the current [LocalContext]. + * The `remember` composable ensures that the same [PermissionHandler] instance is reused across recompositions + * as long as the context remains the same. + * + * @return An instance of [PermissionHandler]. + */ @Composable actual fun getPermissionHandler(): PermissionHandler { val context = LocalContext.current diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt index 136cf0f..9045167 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -19,9 +19,23 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -import de.tillhub.scanengine.data.ScannerEvent -import kotlinx.coroutines.flow.MutableStateFlow +/** + * Manages the camera operations for barcode scanning. + * + * This class is responsible for setting up the camera preview, handling the image analysis + * for barcode detection, and managing the camera lifecycle. It uses CameraX for camera + * operations and ML Kit for barcode scanning. + * + * @property context The application context. + * @property lifecycleOwner The [LifecycleOwner] to which the camera lifecycle will be bound. + * This ensures that camera resources are managed correctly according to the + * lifecycle of the component (e.g., Activity or Fragment) using the camera. + * @property barcodeScanned A lambda function that is invoked when a barcode is successfully scanned. + * It receives the raw value of the scanned barcode as a [String]. + * @property scanner The [BarcodeScanner] instance used for detecting barcodes. By default, it's + * configured to scan all barcode formats. + */ actual class CameraController( private val context: Context, private val lifecycleOwner: LifecycleOwner, @@ -44,7 +58,18 @@ actual class CameraController( private val executor = ContextCompat.getMainExecutor(context) - fun bindCamera(previewView: PreviewView, onCameraReady: () -> Unit = {}) { + /** + * Binds the camera to the provided [PreviewView] and starts the camera session. + * + * This function initializes the camera provider, sets up the preview and image analysis + * use cases, and binds them to the lifecycle of the [lifecycleOwner]. + * + * @param previewView The [PreviewView] where the camera preview will be displayed. + * @param onCameraReady A callback function that is invoked when the camera has been successfully + * bound and is ready to display the preview. This is an optional parameter + * and defaults to an empty function. + */ + internal fun bindCamera(previewView: PreviewView, onCameraReady: () -> Unit = {}) { this.previewView = previewView val cameraProviderFuture = ProcessCameraProvider.getInstance(context) @@ -92,8 +117,17 @@ actual class CameraController( actual fun stopSession() { cameraProvider?.unbindAll() } - - + + /** + * Creates a [ResolutionSelector] with predefined settings. + * + * This function configures the resolution selector to: + * - Use the highest available resolution ([ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY]). + * - Attempt to use a 4:3 aspect ratio, falling back to an automatically selected ratio if 4:3 is not available + * ([AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY]). + * + * @return A configured [ResolutionSelector] instance. + */ private fun createResolutionSelector(): ResolutionSelector { return ResolutionSelector.Builder() .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) @@ -102,12 +136,34 @@ actual class CameraController( } } +/** + * An image analyzer for detecting and processing QR codes from a camera preview. + * This class uses ML Kit's BarcodeScanner to find QR codes in the provided image frames. + * + * @property scanner The [BarcodeScanner] instance used for detecting barcodes. + * @property inputImageGenerator A helper class to convert [ImageProxy] to [InputImage] for the scanner. + * @property barcodeScanned A lambda function that is invoked when a barcode is successfully scanned, + * passing the raw value of the barcode as a String. + */ internal class QRImageAnalyzer( private val scanner: BarcodeScanner, private val inputImageGenerator: InputImageGenerator, private val barcodeScanned: (String) -> Unit ) : ImageAnalysis.Analyzer { + /** + * Analyzes an image from the camera preview to detect barcodes. + * This method is called by the camera framework for each new frame. + * + * It converts the [ImageProxy] to an [InputImage] format suitable for ML Kit's BarcodeScanner. + * If a barcode is detected, the [barcodeScanned] callback is invoked with the raw value + * of the barcode, and the scanner is closed. + * + * Regardless of whether a barcode is found or not, [ImageProxy.close] is called to release + * the image buffer and allow the camera to capture the next frame. + * + * @param imageProxy The image to be analyzed, provided by the camera framework. + */ @androidx.camera.core.ExperimentalGetImage override fun analyze(imageProxy: ImageProxy) { val mediaImage = imageProxy.image @@ -115,7 +171,7 @@ internal class QRImageAnalyzer( val image = inputImageGenerator.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) scanner.process(image).addOnSuccessListener { list -> - if (list.size > 0 && list[0].rawValue != null) { + if (list.isNotEmpty() && list[0].rawValue != null) { barcodeScanned(list[0].rawValue!!) scanner.close() } @@ -126,6 +182,10 @@ internal class QRImageAnalyzer( } } +/** + * A utility class for creating [InputImage] objects from Android [Image] objects. + * This is used to prepare images from the camera for processing by ML Kit's BarcodeScanner. + */ internal class InputImageGenerator { fun fromMediaImage(mediaImage: Image, rotationDegrees: Int): InputImage { return InputImage.fromMediaImage(mediaImage, rotationDegrees) diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt index 5a54d28..f61da6e 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt @@ -12,6 +12,24 @@ import androidx.compose.ui.platform.LocalContext import de.tillhub.scanengine.camera.ui.CameraScanActivity import de.tillhub.scanengine.data.ScannerEvent +/** + * A Composable function that remembers a [CameraScanContract] for launching a camera scanner. + * + * This function handles the lifecycle of the camera scanner and provides a way to launch it + * and receive the results. It uses [rememberLauncherForActivityResult] to manage the + * activity result contract. + * + * @param onResult A callback function that will be invoked with the [ScannerEvent] + * when the camera scanning operation is completed or canceled. + * - [ScannerEvent.ScanResult]: Contains the scanned data if successful. + * - [ScannerEvent.Camera.Canceled]: Indicates the camera scanning was canceled by the user. + * @return A [CameraScanContract] instance that can be used to launch the camera scanner. + * Call [CameraScanContract.launchCameraScanner] on this instance to start the scanning process. + * + * @see CameraScanContract + * @see CameraScanActivity + * @see ScannerEvent + */ @Composable actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit): CameraScanContract { val context = LocalContext.current @@ -47,6 +65,8 @@ actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit): CameraS return remember { object : CameraScanContract { override fun launchCameraScanner(scanKey: String?) { + onResult.invoke(ScannerEvent.Camera.InProgress(scanKey)) + launchCallback.value?.invoke(scanKey) } } diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt index 097bbb2..349d353 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt @@ -7,11 +7,26 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView -import de.tillhub.scanengine.ScanEngine import de.tillhub.scanengine.camera.CameraController -import de.tillhub.scanengine.data.ScannerEvent -import kotlinx.coroutines.flow.MutableStateFlow +/** + * A composable function that displays a camera preview for barcode scanning. + * + * This function utilizes Android's CameraX library to display a live camera feed + * within a Jetpack Compose UI. It's designed to detect barcodes and report them + * via the [barcodeScanned] callback. Any camera-related errors are reported + * through the [onCameraError] callback. + * + * The camera session is managed by a [CameraController] which is lifecycle-aware + * and handles binding the camera to the [PreviewView]. The session is automatically + * started when the composable enters the composition and stopped when it leaves. + * + * @param modifier A [Modifier] to be applied to the camera preview. + * @param barcodeScanned A lambda function that will be invoked with the scanned barcode string + * when a barcode is successfully detected. + * @param onCameraError A lambda function that will be invoked with an error message string + * if any issue occurs during camera initialization or operation. + */ @Composable actual fun cameraPreview( modifier: Modifier, diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt index 3aace97..86f2f86 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt @@ -5,9 +5,28 @@ import de.tillhub.scanengine.camera.contract.CameraScanContract import de.tillhub.scanengine.data.ScannerEvent import kotlinx.coroutines.flow.StateFlow +/** + * Interface that describes a component that provides barcode scanning functionality using the + * device's camera. + */ interface CameraScanner { + /** + * Observe the results of barcode scanning operations. + * + * @return A [StateFlow] that emits [ScannerEvent] objects representing the outcome of scanning + * attempts. This flow can be used to react to successful scans, errors, or other events + * related to the scanning process. + */ fun observeScannerResults(): StateFlow + /** + * Provides a [CameraScanContract] that can be used to launch the camera scanner and + * receive scan results. + * + * This function is composable, meaning it can be used within a Compose UI hierarchy. + * + * @return A [CameraScanContract] instance. + */ @Composable fun cameraScannerLauncher(): CameraScanContract } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt index 3a5e2f5..6742416 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt @@ -1,22 +1,71 @@ package de.tillhub.scanengine -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import de.tillhub.scanengine.camera.DefaultCameraScanner -import de.tillhub.scanengine.camera.ui.CameraScreen import de.tillhub.scanengine.common.SingletonHolder import de.tillhub.scanengine.data.ScannerEvent -import de.tillhub.scanengine.data.ScannerType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.drop +/** + * A central hub for managing and observing scanning events from various sources. + * + * This class acts as a singleton and provides methods to: + * - Observe scanner results as a [Flow] of [ScannerEvent]. + * - Create new instances of [CameraScanner] for camera-based scanning. + * + * The `ScanEngine` manages a [MutableStateFlow] of [ScannerEvent] internally, + * which is updated by the different scanner implementations. + * + * Example usage: + * ```kotlin + * // Obtain the singleton instance + * val scanEngine = ScanEngine.getInstance() + * + * // Observe scanner results + * scanEngine.observeScannerResults().collect { event -> + * when (event) { + * is ScannerEvent.Success -> { + * // Handle successful scan + * println("Scanned code: ${event.scanCode.value}") + * } + * is ScannerEvent.Error -> { + * // Handle error + * println("Scan error: ${event.exception.message}") + * } + * // Handle other event types + * ... + * } + * } + * + * // Create a new camera scanner + * val cameraScanner = scanEngine.newCameraScanner() + * // Use the cameraScanner instance + * ``` + */ class ScanEngine private constructor() { private val mutableScannerEvents = MutableStateFlow(ScannerEvent.External.NotConnected) + /** + * Provides a [Flow] of [ScannerEvent] that emits scanner results. + * + * The first event (initial value) is skipped using `drop(1)` because it's a default + * `ScannerEvent.External.NotConnected` state and not a real scan event. + * Subscribers will receive subsequent events as they are emitted by the connected scanners. + * + * @return A [Flow] of [ScannerEvent] representing scanner results. + */ fun observeScannerResults(): Flow = mutableScannerEvents.drop(1) + /** + * Creates and returns a new instance of [CameraScanner]. + * + * This method provides a way to obtain a dedicated camera scanner instance + * that will report its scan events to this `ScanEngine`. + * + * @return A new [CameraScanner] instance. + */ fun newCameraScanner(): CameraScanner { return DefaultCameraScanner(mutableScannerEvents) } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt index ca84e71..e9eb442 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt @@ -8,6 +8,12 @@ import de.tillhub.scanengine.data.ScannerEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +/** + * A default implementation of [CameraScanner] that uses a [MutableStateFlow] to emit [ScannerEvent]s + * and a [CameraScanContract] to launch the camera scanner. + * + * @param mutableScannerEvents The [MutableStateFlow] to emit [ScannerEvent]s to. + */ internal class DefaultCameraScanner( private val mutableScannerEvents: MutableStateFlow, ) : CameraScanner { @@ -17,17 +23,6 @@ internal class DefaultCameraScanner( @Composable override fun cameraScannerLauncher(): CameraScanContract = rememberCameraScanLauncher { result -> - when (result) { - ScannerEvent.Camera.Canceled -> mutableScannerEvents.tryEmit(result) - is ScannerEvent.ScanResult -> { - val scanKey = (mutableScannerEvents.value as? ScannerEvent.Camera.InProgress)?.scanKey - mutableScannerEvents.tryEmit(result.copy(scanKey = scanKey)) - } - - is ScannerEvent.Camera.InProgress, - is ScannerEvent.External.Connecting, - ScannerEvent.External.NotConnected, - ScannerEvent.External.Connected -> Unit - } + mutableScannerEvents.tryEmit(result) } } \ No newline at end of file diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt new file mode 100644 index 0000000..f24bb58 --- /dev/null +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt @@ -0,0 +1,11 @@ +package de.tillhub.scanengine + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ExampleTest { + @Test + fun example() { + assertEquals(3, 1 + 2) + } +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt index fff9e42..f9e8443 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt @@ -33,6 +33,8 @@ actual fun rememberCameraScanLauncher( override fun launchCameraScanner(scanKey: String?) { val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + onResult.invoke(ScannerEvent.Camera.InProgress(scanKey)) + viewController = ComposeUIViewController { CameraScreen( onResult = { From 98bf2988ff61c4b394bdeff05735800f222c479f Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 3 Jul 2025 15:10:38 +0200 Subject: [PATCH 10/19] [UNTIL-14107] added documentation and made some classes internal --- .../camera/AndroidPermissionHandler.kt | 4 +-- .../contract/CameraScanContract.android.kt | 2 +- .../camera/ui/CameraScreen.android.kt | 2 +- .../scanengine/camera/CameraController.kt | 7 +++++ .../scanengine/camera/DefaultCameraScanner.kt | 10 +++++++ .../scanengine/camera/PermissionHandler.kt | 4 +-- .../camera/contract/CameraScanContract.kt | 15 ++++++++++- .../scanengine/camera/ui/CameraScreen.kt | 27 +++++++++++++++++-- .../scanengine/common/SingletonHolder.kt | 13 +++++++++ .../scanengine/camera/IosPermissionHandler.kt | 2 +- .../camera/contract/CameraScanContract.ios.kt | 2 +- .../scanengine/camera/ui/CameraScreen.ios.kt | 2 +- 12 files changed, 78 insertions(+), 12 deletions(-) diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt index 6c45cd0..12d16f9 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt @@ -16,7 +16,7 @@ import androidx.core.content.ContextCompat * * @param context The application context used to check and request permissions. */ -class AndroidPermissionHandler( +internal class AndroidPermissionHandler( private val context: Context ) : PermissionHandler { /** @@ -83,7 +83,7 @@ class AndroidPermissionHandler( * @return An instance of [PermissionHandler]. */ @Composable -actual fun getPermissionHandler(): PermissionHandler { +internal actual fun getPermissionHandler(): PermissionHandler { val context = LocalContext.current return remember { diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt index f61da6e..0442be6 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt @@ -31,7 +31,7 @@ import de.tillhub.scanengine.data.ScannerEvent * @see ScannerEvent */ @Composable -actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit): CameraScanContract { +internal actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit): CameraScanContract { val context = LocalContext.current val launchCallback = remember { mutableStateOf<((String?) -> Unit)?>(null) } diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt index 349d353..3428e04 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt @@ -28,7 +28,7 @@ import de.tillhub.scanengine.camera.CameraController * if any issue occurs during camera initialization or operation. */ @Composable -actual fun cameraPreview( +internal actual fun cameraPreview( modifier: Modifier, barcodeScanned: (String) -> Unit, onCameraError: (String) -> Unit diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt index 5aee3fb..0aff0ae 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt @@ -1,5 +1,12 @@ package de.tillhub.scanengine.camera +/** + * A controller for managing the camera. + * + * This class provides methods to start and stop the camera session. + * It is an expect class, meaning that the actual implementation will be provided + * by the platform-specific modules (e.g., Android, iOS). + */ expect class CameraController { /** * Starts the camera session. diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt index e9eb442..588c007 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt @@ -18,8 +18,18 @@ internal class DefaultCameraScanner( private val mutableScannerEvents: MutableStateFlow, ) : CameraScanner { + /** + * Observes the results of the camera scanner. + * + * @return A [StateFlow] that emits [ScannerEvent]s when the camera scanner produces a result. + */ override fun observeScannerResults(): StateFlow = mutableScannerEvents + /** + * Launches the camera scanner and emits the result to the [mutableScannerEvents] [MutableStateFlow]. + * + * @return A [CameraScanContract] that can be used to launch the camera scanner. + */ @Composable override fun cameraScannerLauncher(): CameraScanContract = rememberCameraScanLauncher { result -> diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt index 2cfe962..3b44a03 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable * This interface provides methods to check and request camera permission, * crucial for features that require access to the device's camera. */ -interface PermissionHandler { +internal interface PermissionHandler { /** * Checks if the camera permission is granted. * @@ -27,4 +27,4 @@ interface PermissionHandler { } @Composable -expect fun getPermissionHandler(): PermissionHandler \ No newline at end of file +internal expect fun getPermissionHandler(): PermissionHandler \ No newline at end of file diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt index 3e05d1d..5bd4c04 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt @@ -10,7 +10,20 @@ interface CameraScanContract { fun launchCameraScanner(scanKey: String? = null) } +/** + * A Composable function that remembers a [CameraScanContract] across recompositions. + * + * This function is designed to be used in Compose UI to get an instance of [CameraScanContract] + * which can then be used to launch the camera scanner. The provided [onResult] lambda + * will be invoked when the scanner produces a result (either a successful scan or an error). + * + * @param onResult A lambda function that will be called with a [ScannerEvent] + * representing the outcome of the scan operation. This can be a + * [ScannerEvent.Success] containing the scanned data, or an + * intermediary state such as [ScannerEvent.InProgress]. + * @return An instance of [CameraScanContract] that can be used to initiate a camera scan. + */ @Composable -expect fun rememberCameraScanLauncher( +internal expect fun rememberCameraScanLauncher( onResult: (ScannerEvent) -> Unit ): CameraScanContract \ No newline at end of file diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index 30ca718..6377aef 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -26,9 +26,22 @@ import de.tillhub.scanengine.resources.permission_camera_request import de.tillhub.scanengine.resources.permission_required_message import de.tillhub.scanengine.resources.permission_required_title +/** + * A Composable function that displays a camera screen for barcode scanning. + * + * This screen handles camera permission requests and displays appropriate UI based on the permission status. + * If permission is granted, it shows the camera preview for scanning. + * If permission is denied, it shows a message requesting permission. + * If there's a camera error, it displays an error message. + * + * @param onResult A callback function that is invoked when a barcode is successfully scanned. + * It receives the scanned barcode string as a parameter. + * @param onDismiss A callback function that is invoked when the user dismisses the screen + * (e.g., by clicking the back button in the toolbar). + */ @Preview @Composable -fun CameraScreen( +internal fun CameraScreen( onResult: (String) -> Unit, onDismiss: () -> Unit ) { @@ -110,8 +123,18 @@ fun CameraScreen( } } +/** + * A Composable function that displays the camera preview for barcode scanning. + * This is an expect function, meaning its actual implementation is provided by the platform-specific code (Android or iOS). + * + * @param modifier Modifier to be applied to the camera preview. + * @param barcodeScanned A callback function that is invoked when a barcode is successfully scanned. + * It receives the scanned barcode string as a parameter. + * @param onCameraError A callback function that is invoked when an error occurs with the camera. + * It receives an error message string as a parameter. + */ @Composable -expect fun cameraPreview( +internal expect fun cameraPreview( modifier: Modifier, barcodeScanned: (String) -> Unit, onCameraError: (String) -> Unit diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt index ee9efed..246f26d 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt @@ -5,6 +5,19 @@ import kotlinx.coroutines.internal.SynchronizedObject import kotlinx.coroutines.internal.synchronized import kotlin.concurrent.Volatile +/** + * A generic class for creating and managing singleton instances. + * This class ensures that only one instance of a given type [T] is created and provides a thread-safe way to access it. + * + * @param T The type of the singleton instance. It must be a non-nullable type that inherits from [Any]. + * @property creator A lambda function that creates the instance of [T]. This function is invoked only once, the first time [getInstance] is called. + * After the instance is created, this property is set to `null` to release the reference to the creator function. + * @property instance The singleton instance of type [T]. It is initialized lazily when [getInstance] is called for the first time. + * This property is marked as `@Volatile` to ensure visibility across threads. + * + * This class inherits from [SynchronizedObject] and uses its `synchronized` block to ensure thread-safe initialization of the singleton instance. + * The `@OptIn(InternalCoroutinesApi::class)` annotation is used because [SynchronizedObject] and its `synchronized` function are part of Kotlin's internal coroutines API. + */ @OptIn(InternalCoroutinesApi::class) open class SingletonHolder(creator: () -> T) : SynchronizedObject() { private var creator: (() -> T)? = creator diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt index ea2ff35..b09928b 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt @@ -56,7 +56,7 @@ internal class IosPermissionHandler : PermissionHandler { } @Composable -actual fun getPermissionHandler(): PermissionHandler { +internal actual fun getPermissionHandler(): PermissionHandler { return remember { IosPermissionHandler() } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt index f9e8443..49ca307 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt @@ -15,7 +15,7 @@ import platform.UIKit.UIViewController * @param onResult a callback for the result of the camera scan. */ @Composable -actual fun rememberCameraScanLauncher( +internal actual fun rememberCameraScanLauncher( onResult: (ScannerEvent) -> Unit ): CameraScanContract = remember { object : CameraScanContract { diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index def0f85..29ef7ff 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -33,7 +33,7 @@ import platform.UIKit.UIDeviceOrientationDidChangeNotification * or operation. It receives an error message string as a parameter. */ @Composable -actual fun cameraPreview( +internal actual fun cameraPreview( modifier: Modifier, barcodeScanned: (String) -> Unit, onCameraError: (String) -> Unit From f2a698799d8bed785e78250761ed8c8c152075a2 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 7 Jul 2025 16:20:01 +0200 Subject: [PATCH 11/19] [UNTIL-14107] set up testing and added some tests --- gradle/libs.versions.toml | 8 +- scan_engine/build.gradle.kts | 26 +++- .../de/tillhub/scanengine/ScanEngine.kt | 4 - .../scanengine/camera/ui/CameraScreen.kt | 29 ++-- .../de/tillhub/scanengine/ExampleTest.kt | 11 -- .../de/tillhub/scanengine/ScanEngineTest.kt | 23 ++++ .../camera/DefaultCameraScannerTest.kt | 38 ++++++ .../scanengine/camera/ui/CameraScreenTest.kt | 127 ++++++++++++++++++ 8 files changed, 239 insertions(+), 27 deletions(-) delete mode 100644 scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt create mode 100644 scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt create mode 100644 scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt create mode 100644 scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4980f7c..4bee0c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,9 @@ activityCompose = "1.10.1" spotless = "6.21.0" + +uiTestJunit4 = "1.8.3" + [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -27,7 +30,6 @@ camera-extensions = { module = "androidx.camera:camera-extensions", version.ref camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera-lifecycle" } camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camera-view" } - androidx-lifecycle-runtimeCompose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } @@ -37,6 +39,10 @@ activity-compose = { group = "androidx.activity", name = "activity-compose", ver google-mlkit = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlKit" } google-play-services-mlkit = { group = "com.google.android.gms", name = "play-services-mlkit-barcode-scanning", version.ref = "playServicesMlkit" } +# Android testing +androidx-ui-test-junit4-android = { group = "androidx.compose.ui", name = "ui-test-junit4-android", version.ref = "uiTestJunit4" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } + [plugins] android_library = { id = "com.android.library", version.ref = "agp" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/scan_engine/build.gradle.kts b/scan_engine/build.gradle.kts index f67d95c..6970465 100644 --- a/scan_engine/build.gradle.kts +++ b/scan_engine/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree @@ -17,6 +18,11 @@ kotlin { androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) + + dependencies { + androidTestImplementation(libs.androidx.ui.test.junit4.android) + androidTestImplementation(libs.androidx.ui.test.manifest) + } } val xcfName = "scan_engine" @@ -39,7 +45,16 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) } commonTest.dependencies { - implementation(libs.kotlin.test) + implementation(kotlin("test")) + + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + } + iosTest.dependencies { + implementation(kotlin("test")) + + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.uiTest) } androidMain.dependencies { implementation(libs.bundles.camera) @@ -68,6 +83,15 @@ android { sourceCompatibility = Configs.JAVA_VERSION targetCompatibility = Configs.JAVA_VERSION } + + testOptions { + unitTests { + all { + // We want to exclude all UI tests from the unit tests + it.exclude("**/scanengine/camera/ui/**") + } + } + } } compose.resources { diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt index 6742416..716bcce 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt @@ -29,10 +29,6 @@ import kotlinx.coroutines.flow.drop * // Handle successful scan * println("Scanned code: ${event.scanCode.value}") * } - * is ScannerEvent.Error -> { - * // Handle error - * println("Scan error: ${event.exception.message}") - * } * // Handle other event types * ... * } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index 6377aef..628002c 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import de.tillhub.scanengine.camera.PermissionHandler import de.tillhub.scanengine.camera.getPermissionHandler @@ -34,6 +36,8 @@ import de.tillhub.scanengine.resources.permission_required_title * If permission is denied, it shows a message requesting permission. * If there's a camera error, it displays an error message. * + * @param permissions An instance of [PermissionHandler] used to manage camera permissions. + * Defaults to a platform-specific implementation obtained via [getPermissionHandler]. * @param onResult A callback function that is invoked when a barcode is successfully scanned. * It receives the scanned barcode string as a parameter. * @param onDismiss A callback function that is invoked when the user dismisses the screen @@ -42,10 +46,10 @@ import de.tillhub.scanengine.resources.permission_required_title @Preview @Composable internal fun CameraScreen( + permissions: PermissionHandler = getPermissionHandler(), onResult: (String) -> Unit, onDismiss: () -> Unit ) { - val permissions: PermissionHandler = getPermissionHandler() val hasPermission = remember { mutableStateOf(permissions.hasCameraPermission()) } val askForPermission = remember { mutableStateOf(false) } @@ -76,10 +80,12 @@ internal fun CameraScreen( ) { Text( style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding( - vertical = 16.dp, - horizontal = 8.dp - ), + modifier = Modifier + .padding( + vertical = 16.dp, + horizontal = 8.dp + ) + .semantics { contentDescription = "Camera Error" }, text = stringResource(Res.string.camera_error) ) } @@ -87,7 +93,8 @@ internal fun CameraScreen( hasPermission.value -> cameraPreview( modifier = Modifier .padding(innerPadding) - .padding(top = 16.dp), + .padding(top = 16.dp) + .semantics { contentDescription = "Camera preview" }, barcodeScanned = onResult, onCameraError = { error -> cameraError.value = true @@ -106,10 +113,12 @@ internal fun CameraScreen( ) { Text( style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding( - vertical = 16.dp, - horizontal = 16.dp - ), + modifier = Modifier + .padding( + vertical = 16.dp, + horizontal = 16.dp + ) + .semantics { contentDescription = "Permission explanation" }, text = stringResource(Res.string.permission_required_message) ) BottomButton( diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt deleted file mode 100644 index f24bb58..0000000 --- a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ExampleTest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package de.tillhub.scanengine - -import kotlin.test.Test -import kotlin.test.assertEquals - -class ExampleTest { - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} \ No newline at end of file diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt new file mode 100644 index 0000000..68967bd --- /dev/null +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt @@ -0,0 +1,23 @@ +package de.tillhub.scanengine + +import de.tillhub.scanengine.camera.DefaultCameraScanner +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class ScanEngineTest { + + lateinit var scanEngine: ScanEngine + + @BeforeTest + fun setup() { + scanEngine = ScanEngine.getInstance() + } + + @Test + fun testCameraScanner() { + val cameraScanner = scanEngine.newCameraScanner() + + assertTrue(cameraScanner is DefaultCameraScanner) + } +} \ No newline at end of file diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt new file mode 100644 index 0000000..c4662b5 --- /dev/null +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt @@ -0,0 +1,38 @@ +package de.tillhub.scanengine.camera + +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DefaultCameraScannerTest { + private val testFlow = MutableStateFlow(ScannerEvent.Camera.InProgress(null)) + + private lateinit var target: DefaultCameraScanner + + @BeforeTest + fun testSetup() { + target = DefaultCameraScanner(testFlow) + } + + @Test + fun testScannerResults() = runTest { + val result1 = target.observeScannerResults().firstOrNull() + + assertTrue(result1 is ScannerEvent.Camera.InProgress) + assertNull(result1.scanKey) + + testFlow.value = ScannerEvent.ScanResult("test") + + val result2 = target.observeScannerResults().firstOrNull() + + assertTrue(result2 is ScannerEvent.ScanResult ) + assertNull(result2.scanKey) + assertEquals("test", result2.value) + } +} \ No newline at end of file diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt new file mode 100644 index 0000000..41fcaeb --- /dev/null +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt @@ -0,0 +1,127 @@ +package de.tillhub.scanengine.camera.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import de.tillhub.scanengine.camera.PermissionHandler +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class CameraScreenTest { + + private var hasPermission = false + private var permissionGranted: () -> Unit = {} + private var permissionDenied: () -> Unit = {} + + private val permissionHandler = object : PermissionHandler { + override fun hasCameraPermission(): Boolean = hasPermission + + @Composable + override fun requestCameraPermission( + onGranted: () -> Unit, + onDenied: () -> Unit + ) { + permissionGranted = onGranted + permissionDenied = onDenied + Column( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .padding( + vertical = 16.dp, + horizontal = 16.dp + ) + .semantics { contentDescription = "Permission request" }, + text = "Permission request" + ) + } + } + + } + + @Test + fun missingPermissions_showPermissionExplanation() = runComposeUiTest { + setContent { + CameraScreen( + permissions = permissionHandler, + onResult = {}, + onDismiss = {} + ) + } + + onNodeWithContentDescription("Permission explanation").assertExists() + onNodeWithTag("submitButton").assertExists() + } + + @Test + fun missingPermissions_showPermissionRequest() = runComposeUiTest { + setContent { + CameraScreen( + permissions = permissionHandler, + onResult = {}, + onDismiss = {} + ) + } + + onNodeWithTag("submitButton").performClick() + + onNodeWithContentDescription("Permission request").assertExists() + } + + @Test + fun missingPermissions_denyPermission() = runComposeUiTest { + setContent { + CameraScreen( + permissions = permissionHandler, + onResult = {}, + onDismiss = {} + ) + } + + onNodeWithTag("submitButton").performClick() + + onNodeWithContentDescription("Permission request").assertExists() + + permissionDenied() + + onNodeWithContentDescription("Permission explanation").assertExists() + onNodeWithTag("submitButton").assertExists() + } + + @Test + fun missingPermissions_grantPermission() = runComposeUiTest { + setContent { + CameraScreen( + permissions = permissionHandler, + onResult = {}, + onDismiss = {} + ) + } + + onNodeWithTag("submitButton").performClick() + + onNodeWithContentDescription("Permission request").assertExists() + + permissionGranted() + + onNodeWithContentDescription("Camera preview").assertExists() + } +} \ No newline at end of file From 6874ee6be328ce792906ecfac82637a6f97d0227 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 8 Jul 2025 10:39:32 +0200 Subject: [PATCH 12/19] [UNTIL-14107] applied spotless fixes --- .github/workflows/pr-checks.yml | 4 +- scan_engine/build.gradle.kts | 2 +- .../de/tillhub/scanengine/Platform.android.kt | 2 +- .../camera/AndroidPermissionHandler.kt | 11 +++--- .../camera/CameraController.android.kt | 17 ++++----- .../contract/CameraScanContract.android.kt | 6 +-- .../camera/ui/CameraScanActivity.kt | 4 +- .../camera/ui/CameraScreen.android.kt | 4 +- .../de/tillhub/scanengine/BarcodeScanner.kt | 2 +- .../kotlin/de/tillhub/scanengine/Platform.kt | 2 +- .../scanengine/camera/CameraController.kt | 2 +- .../scanengine/camera/DefaultCameraScanner.kt | 2 +- .../scanengine/camera/PermissionHandler.kt | 2 +- .../camera/contract/CameraScanContract.kt | 4 +- .../scanengine/camera/ui/CameraScreen.kt | 37 +++++++++---------- .../scanengine/common/SingletonHolder.kt | 1 + .../tillhub/scanengine/data/ScannerEvent.kt | 4 +- .../scanengine/data/ScannerResponse.kt | 1 - .../de/tillhub/scanengine/data/ScannerType.kt | 3 +- .../scanengine/ui/components/BottomButton.kt | 11 +++--- .../scanengine/ui/components/Toolbar.kt | 12 +++--- .../de/tillhub/scanengine/ui/theme/Style.kt | 2 +- .../de/tillhub/scanengine/ui/theme/Theme.kt | 6 +-- .../de/tillhub/scanengine/ui/theme/Type.kt | 2 +- .../de/tillhub/scanengine/ScanEngineTest.kt | 2 +- .../camera/DefaultCameraScannerTest.kt | 4 +- .../scanengine/camera/ui/CameraScreenTest.kt | 19 +++++----- .../de/tillhub/scanengine/Platform.ios.kt | 4 +- .../scanengine/camera/CameraController.ios.kt | 12 +++--- .../scanengine/camera/CameraWrapper.kt | 18 +++++---- .../scanengine/camera/IosPermissionHandler.kt | 5 +-- .../scanengine/camera/common/DispatchAsync.kt | 6 +-- .../camera/contract/CameraScanContract.ios.kt | 10 ++--- .../scanengine/camera/ui/CameraScreen.ios.kt | 11 ++---- 34 files changed, 114 insertions(+), 120 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index ded0463..d87baab 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -22,8 +22,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Run Detekt Code Analysis - run: ./gradlew detekt + - name: Run Spotless Code Analysis + run: ./gradlew spotlessCheck - name: Run Unit tests run: ./gradlew testDebug diff --git a/scan_engine/build.gradle.kts b/scan_engine/build.gradle.kts index 6970465..0e5da42 100644 --- a/scan_engine/build.gradle.kts +++ b/scan_engine/build.gradle.kts @@ -97,4 +97,4 @@ android { compose.resources { packageOfResClass = "de.tillhub.scanengine.resources" generateResClass = auto -} \ No newline at end of file +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt index 6f106e9..a6e1fe6 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt @@ -5,4 +5,4 @@ class AndroidPlatform : Platform { override val manufacturer: String = android.os.Build.MANUFACTURER } -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file +actual fun getPlatform(): Platform = AndroidPlatform() diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt index 12d16f9..c6dafe8 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt @@ -17,7 +17,7 @@ import androidx.core.content.ContextCompat * @param context The application context used to check and request permissions. */ internal class AndroidPermissionHandler( - private val context: Context + private val context: Context, ) : PermissionHandler { /** * Checks if the app has been granted camera permission. @@ -27,7 +27,7 @@ internal class AndroidPermissionHandler( override fun hasCameraPermission(): Boolean { return ContextCompat.checkSelfPermission( context, - Manifest.permission.CAMERA + Manifest.permission.CAMERA, ) == PackageManager.PERMISSION_GRANTED } @@ -52,18 +52,17 @@ internal class AndroidPermissionHandler( } else { onDenied() } - } + }, ) val permissionStatus = ContextCompat.checkSelfPermission( context, - Manifest.permission.CAMERA + Manifest.permission.CAMERA, ) when (permissionStatus) { PackageManager.PERMISSION_GRANTED -> onGranted() PackageManager.PERMISSION_DENIED -> { - LaunchedEffect(Unit) { launcher.launch(Manifest.permission.CAMERA) } @@ -89,4 +88,4 @@ internal actual fun getPermissionHandler(): PermissionHandler { return remember { AndroidPermissionHandler(context) } -} \ No newline at end of file +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt index 9045167..421606c 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -41,14 +41,14 @@ actual class CameraController( private val lifecycleOwner: LifecycleOwner, barcodeScanned: (String) -> Unit, scanner: BarcodeScanner = BarcodeScanning.getClient( - BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build() - ) + BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build(), + ), ) { private val analyzer: ImageAnalysis.Analyzer = QRImageAnalyzer( scanner = scanner, inputImageGenerator = InputImageGenerator(), - barcodeScanned = barcodeScanned + barcodeScanned = barcodeScanned, ) private var cameraProvider: ProcessCameraProvider? = null @@ -96,13 +96,12 @@ actual class CameraController( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, - imageAnalyzer + imageAnalyzer, ) onCameraReady() - }, - executor + executor, ) } @@ -117,7 +116,7 @@ actual class CameraController( actual fun stopSession() { cameraProvider?.unbindAll() } - + /** * Creates a [ResolutionSelector] with predefined settings. * @@ -148,7 +147,7 @@ actual class CameraController( internal class QRImageAnalyzer( private val scanner: BarcodeScanner, private val inputImageGenerator: InputImageGenerator, - private val barcodeScanned: (String) -> Unit + private val barcodeScanned: (String) -> Unit, ) : ImageAnalysis.Analyzer { /** @@ -190,4 +189,4 @@ internal class InputImageGenerator { fun fromMediaImage(mediaImage: Image, rotationDegrees: Int): InputImage { return InputImage.fromMediaImage(mediaImage, rotationDegrees) } -} \ No newline at end of file +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt index 0442be6..ea33a57 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt @@ -38,14 +38,14 @@ internal actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit) var scanKey: String? = null val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() + contract = ActivityResultContracts.StartActivityForResult(), ) { result -> val resultData = result.data?.extras?.getString(CameraScanActivity.DATA_KEY) val scanResult = if (result.resultCode == Activity.RESULT_OK && !resultData.isNullOrEmpty()) { ScannerEvent.ScanResult( value = resultData, - scanKey = scanKey + scanKey = scanKey, ) } else { ScannerEvent.Camera.Canceled @@ -71,4 +71,4 @@ internal actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit) } } } -} \ No newline at end of file +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt index 25f79f1..e8e0a2b 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt @@ -21,7 +21,7 @@ class CameraScanActivity : ComponentActivity() { onDismiss = { setResult(RESULT_CANCELED) finish() - } + }, ) } } @@ -29,4 +29,4 @@ class CameraScanActivity : ComponentActivity() { companion object { const val DATA_KEY = "scanned_data" } -} \ No newline at end of file +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt index 3428e04..0553701 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt @@ -31,7 +31,7 @@ import de.tillhub.scanengine.camera.CameraController internal actual fun cameraPreview( modifier: Modifier, barcodeScanned: (String) -> Unit, - onCameraError: (String) -> Unit + onCameraError: (String) -> Unit, ) { val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current @@ -52,4 +52,4 @@ internal actual fun cameraPreview( factory = { previewView }, modifier = modifier, ) -} \ No newline at end of file +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt index 1747507..3138e4f 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt @@ -1,7 +1,7 @@ package de.tillhub.scanengine -import de.tillhub.scanengine.data.ScannerEvent import de.tillhub.scanengine.data.Scanner +import de.tillhub.scanengine.data.ScannerEvent import de.tillhub.scanengine.data.ScannerResponse import de.tillhub.scanengine.data.ScannerType import kotlinx.coroutines.flow.Flow diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt index 0be5833..f8b10c0 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt @@ -5,4 +5,4 @@ interface Platform { val manufacturer: String } -expect fun getPlatform(): Platform \ No newline at end of file +expect fun getPlatform(): Platform diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt index 0aff0ae..50a2d68 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt @@ -17,4 +17,4 @@ expect class CameraController { * Stops the camera session. */ fun stopSession() -} \ No newline at end of file +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt index 588c007..cf8ee86 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt @@ -35,4 +35,4 @@ internal class DefaultCameraScanner( rememberCameraScanLauncher { result -> mutableScannerEvents.tryEmit(result) } -} \ No newline at end of file +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt index 3b44a03..33be74e 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt @@ -27,4 +27,4 @@ internal interface PermissionHandler { } @Composable -internal expect fun getPermissionHandler(): PermissionHandler \ No newline at end of file +internal expect fun getPermissionHandler(): PermissionHandler diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt index 5bd4c04..0be0e15 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt @@ -25,5 +25,5 @@ interface CameraScanContract { */ @Composable internal expect fun rememberCameraScanLauncher( - onResult: (ScannerEvent) -> Unit -): CameraScanContract \ No newline at end of file + onResult: (ScannerEvent) -> Unit, +): CameraScanContract diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index 628002c..a6e47c4 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -16,17 +16,17 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import de.tillhub.scanengine.camera.PermissionHandler import de.tillhub.scanengine.camera.getPermissionHandler -import de.tillhub.scanengine.ui.components.BottomButton -import de.tillhub.scanengine.ui.components.Toolbar -import de.tillhub.scanengine.ui.theme.AppTheme -import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview import de.tillhub.scanengine.resources.Res import de.tillhub.scanengine.resources.camera_error import de.tillhub.scanengine.resources.camera_title import de.tillhub.scanengine.resources.permission_camera_request import de.tillhub.scanengine.resources.permission_required_message import de.tillhub.scanengine.resources.permission_required_title +import de.tillhub.scanengine.ui.components.BottomButton +import de.tillhub.scanengine.ui.components.Toolbar +import de.tillhub.scanengine.ui.theme.AppTheme +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview /** * A Composable function that displays a camera screen for barcode scanning. @@ -48,9 +48,8 @@ import de.tillhub.scanengine.resources.permission_required_title internal fun CameraScreen( permissions: PermissionHandler = getPermissionHandler(), onResult: (String) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { - val hasPermission = remember { mutableStateOf(permissions.hasCameraPermission()) } val askForPermission = remember { mutableStateOf(false) } val cameraError = remember { mutableStateOf(false) } @@ -65,9 +64,9 @@ internal fun CameraScreen( } else { stringResource(Res.string.permission_required_title) }, - onClick = { onDismiss() } + onClick = { onDismiss() }, ) - } + }, ) { innerPadding -> when { cameraError.value -> { @@ -76,17 +75,17 @@ internal fun CameraScreen( .padding(innerPadding) .padding(top = 16.dp) .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.SpaceBetween, ) { Text( style = MaterialTheme.typography.labelLarge, modifier = Modifier .padding( vertical = 16.dp, - horizontal = 8.dp + horizontal = 8.dp, ) .semantics { contentDescription = "Camera Error" }, - text = stringResource(Res.string.camera_error) + text = stringResource(Res.string.camera_error), ) } } @@ -98,32 +97,32 @@ internal fun CameraScreen( barcodeScanned = onResult, onCameraError = { error -> cameraError.value = true - } + }, ) askForPermission.value -> permissions.requestCameraPermission( onGranted = { hasPermission.value = true }, - onDenied = { askForPermission.value = false } + onDenied = { askForPermission.value = false }, ) else -> { Column( modifier = Modifier .padding(innerPadding) .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.SpaceBetween, ) { Text( style = MaterialTheme.typography.labelLarge, modifier = Modifier .padding( vertical = 16.dp, - horizontal = 16.dp + horizontal = 16.dp, ) .semantics { contentDescription = "Permission explanation" }, - text = stringResource(Res.string.permission_required_message) + text = stringResource(Res.string.permission_required_message), ) BottomButton( text = stringResource(Res.string.permission_camera_request), - onClick = { askForPermission.value = true } + onClick = { askForPermission.value = true }, ) } } @@ -146,5 +145,5 @@ internal fun CameraScreen( internal expect fun cameraPreview( modifier: Modifier, barcodeScanned: (String) -> Unit, - onCameraError: (String) -> Unit + onCameraError: (String) -> Unit, ) diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt index 246f26d..d121ca3 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt @@ -21,6 +21,7 @@ import kotlin.concurrent.Volatile @OptIn(InternalCoroutinesApi::class) open class SingletonHolder(creator: () -> T) : SynchronizedObject() { private var creator: (() -> T)? = creator + @Volatile private var instance: T? = null diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt index af0b8b9..d514c86 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt @@ -14,11 +14,11 @@ sealed class ScannerEvent { class ScanResult( val value: String, - val scanKey: String? = null + val scanKey: String? = null, ) : ScannerEvent() { fun copy( value: String = this.value, - scanKey: String? = this.scanKey + scanKey: String? = this.scanKey, ) = ScanResult(value, scanKey) } } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt index 831f978..348399a 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt @@ -1,6 +1,5 @@ package de.tillhub.scanengine.data - sealed class ScannerResponse { sealed class Error : ScannerResponse() { data object NotFound : Error() diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt index a2c86ef..7e03164 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt @@ -5,7 +5,8 @@ import de.tillhub.scanengine.getPlatform enum class ScannerType(val value: String) { SUNMI("SUNMI"), ZEBRA("ZEBRA"), - UNKNOWN("UNKNOWN"); + UNKNOWN("UNKNOWN"), + ; companion object { fun get(): ScannerType = diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt index 128337c..59a214d 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import de.tillhub.scanengine.ui.theme.MagneticGrey import de.tillhub.scanengine.ui.theme.OrbitalBlue -import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -21,7 +20,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview internal fun BottomButton( isEnable: Boolean = true, text: String, - onClick: () -> Unit = {} + onClick: () -> Unit = {}, ) { Button( enabled = isEnable, @@ -31,13 +30,13 @@ internal fun BottomButton( shape = RectangleShape, onClick = onClick, colors = ButtonDefaults.buttonColors( - containerColor = if (isEnable) OrbitalBlue else MagneticGrey - ) + containerColor = if (isEnable) OrbitalBlue else MagneticGrey, + ), ) { Text( style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(vertical = 8.dp), - text = text + text = text, ) } -} \ No newline at end of file +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt index d6df8e1..71f0adf 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt @@ -22,7 +22,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable internal fun Toolbar( title: String, - onClick: () -> Unit + onClick: () -> Unit, ) { Column { TopAppBar( @@ -32,20 +32,20 @@ internal fun Toolbar( Text( title, style = MaterialTheme.typography.titleLarge, - modifier = Modifier.testTag("toolbarTitle") + modifier = Modifier.testTag("toolbarTitle"), ) }, navigationIcon = { IconButton( onClick = onClick, - modifier = Modifier.testTag("toolbarIcon") + modifier = Modifier.testTag("toolbarIcon"), ) { Icon( imageVector = Icons.Filled.Close, - contentDescription = "close" + contentDescription = "close", ) } - } + }, ) HorizontalDivider() } @@ -54,4 +54,4 @@ internal fun Toolbar( @Preview @Composable internal fun ToolbarPreview() { Toolbar("Title example") {} -} \ No newline at end of file +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt index 0fbf0e6..416485a 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt @@ -17,7 +17,7 @@ internal fun buttonElevation() = buttonElevation( pressedElevation = 1.dp, disabledElevation = 1.dp, hoveredElevation = 2.dp, - focusedElevation = 2.dp + focusedElevation = 2.dp, ) @Composable diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt index b4ba2bc..1f4357f 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt @@ -5,10 +5,10 @@ import androidx.compose.runtime.Composable @Composable internal fun AppTheme( - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { MaterialTheme( typography = typography, - content = content + content = content, ) -} \ No newline at end of file +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt index d0bab37..2557f21 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt @@ -96,4 +96,4 @@ internal val typography = Typography( lineHeight = 16.sp, letterSpacing = 0.5.sp, ), -) \ No newline at end of file +) diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt index 68967bd..7421611 100644 --- a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt @@ -20,4 +20,4 @@ class ScanEngineTest { assertTrue(cameraScanner is DefaultCameraScanner) } -} \ No newline at end of file +} diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt index c4662b5..8a1c5dc 100644 --- a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt @@ -31,8 +31,8 @@ class DefaultCameraScannerTest { val result2 = target.observeScannerResults().firstOrNull() - assertTrue(result2 is ScannerEvent.ScanResult ) + assertTrue(result2 is ScannerEvent.ScanResult) assertNull(result2.scanKey) assertEquals("test", result2.value) } -} \ No newline at end of file +} diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt index 41fcaeb..8813cd9 100644 --- a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt @@ -32,7 +32,7 @@ class CameraScreenTest { @Composable override fun requestCameraPermission( onGranted: () -> Unit, - onDenied: () -> Unit + onDenied: () -> Unit, ) { permissionGranted = onGranted permissionDenied = onDenied @@ -40,21 +40,20 @@ class CameraScreenTest { modifier = Modifier .padding(top = 16.dp) .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.SpaceBetween, ) { Text( style = MaterialTheme.typography.labelLarge, modifier = Modifier .padding( vertical = 16.dp, - horizontal = 16.dp + horizontal = 16.dp, ) .semantics { contentDescription = "Permission request" }, - text = "Permission request" + text = "Permission request", ) } } - } @Test @@ -63,7 +62,7 @@ class CameraScreenTest { CameraScreen( permissions = permissionHandler, onResult = {}, - onDismiss = {} + onDismiss = {}, ) } @@ -77,7 +76,7 @@ class CameraScreenTest { CameraScreen( permissions = permissionHandler, onResult = {}, - onDismiss = {} + onDismiss = {}, ) } @@ -92,7 +91,7 @@ class CameraScreenTest { CameraScreen( permissions = permissionHandler, onResult = {}, - onDismiss = {} + onDismiss = {}, ) } @@ -112,7 +111,7 @@ class CameraScreenTest { CameraScreen( permissions = permissionHandler, onResult = {}, - onDismiss = {} + onDismiss = {}, ) } @@ -124,4 +123,4 @@ class CameraScreenTest { onNodeWithContentDescription("Camera preview").assertExists() } -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt index d4335d9..4120775 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt @@ -2,9 +2,9 @@ package de.tillhub.scanengine import platform.UIKit.UIDevice -class IOSPlatform: Platform { +class IOSPlatform : Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion override val manufacturer: String = "apple" } -actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file +actual fun getPlatform(): Platform = IOSPlatform() diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index 8ff0e5f..e8af761 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -46,7 +46,7 @@ actual class CameraController( private val cameraWrapper: CameraWrapper = CameraWrapper(), private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput(), private val onCameraError: (String) -> Unit, - barcodeScanned: (String) -> Unit + barcodeScanned: (String) -> Unit, ) : UIViewController(null, null) { private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned) @@ -173,7 +173,7 @@ actual class CameraController( AVMetadataObjectTypePDF417Code!!, AVMetadataObjectTypeAztecCode!!, AVMetadataObjectTypeDataMatrixCode!!, - AVMetadataObjectTypeUPCECode!! + AVMetadataObjectTypeUPCECode!!, ) } } @@ -193,7 +193,7 @@ actual class CameraController( */ internal class QRImageAnalyzer( private val onCodeScanned: (String) -> Unit, - private val debounceMs: Long = 1000L + private val debounceMs: Long = 1000L, ) : NSObject(), AVCaptureMetadataOutputObjectsDelegateProtocol { private val isProcessing = atomic(false) @@ -218,14 +218,14 @@ internal class QRImageAnalyzer( override fun captureOutput( output: AVCaptureOutput, didOutputMetadataObjects: List<*>, - fromConnection: AVCaptureConnection + fromConnection: AVCaptureConnection, ) { if (isProcessing.value) return didOutputMetadataObjects.firstOrNull { it is AVMetadataMachineReadableCodeObject && - !it.stringValue.isNullOrEmpty() && - it.stringValue != lastScannedCode + !it.stringValue.isNullOrEmpty() && + it.stringValue != lastScannedCode }?.let { scannedCode -> (scannedCode as AVMetadataMachineReadableCodeObject).stringValue?.let { processCode(it) diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt index 2ec4db2..ba50ba1 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt @@ -40,7 +40,7 @@ import platform.darwin.NSObject * @property cameraPreviewLayer The `AVCaptureVideoPreviewLayer` used to display the camera feed, if set up. * @property onError A callback function that is invoked when a [CameraException] occurs. */ -class CameraWrapper: NSObject() { +class CameraWrapper : NSObject() { private var currentCamera: AVCaptureDevice? = null var captureSession: AVCaptureSession? = null var cameraPreviewLayer: AVCaptureVideoPreviewLayer? = null @@ -59,7 +59,7 @@ class CameraWrapper: NSObject() { * If input setup fails, it throws a [CameraException.DeviceNotAvailable]. * If any [CameraException] occurs during setup, the session is cleaned up using [cleanupSession], * and the [onError] callback is invoked with the exception. - */ + */ internal fun setupSession() { try { captureSession = AVCaptureSession() @@ -181,16 +181,18 @@ class CameraWrapper: NSObject() { val availableDevices = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), AVMediaTypeVideo, - AVCaptureDevicePositionUnspecified + AVCaptureDevicePositionUnspecified, ).devices - (availableDevices.find { - (it as AVCaptureDevice).position == AVCaptureDevicePositionBack - } as? AVCaptureDevice)?.let { currentCamera -> + ( + availableDevices.find { + (it as AVCaptureDevice).position == AVCaptureDevicePositionBack + } as? AVCaptureDevice + )?.let { currentCamera -> try { val input = AVCaptureDeviceInput.deviceInputWithDevice( currentCamera, - null + null, ) if (input != null && captureSession?.canAddInput(input) == true) { @@ -203,4 +205,4 @@ class CameraWrapper: NSObject() { } return false } -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt index b09928b..e0f733d 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt @@ -43,7 +43,7 @@ internal class IosPermissionHandler : PermissionHandler { @Composable override fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { AVCaptureDevice.requestAccessForMediaType( - AVMediaTypeVideo + AVMediaTypeVideo, ) { granted -> if (granted) { onGranted() @@ -52,7 +52,6 @@ internal class IosPermissionHandler : PermissionHandler { } } } - } @Composable @@ -60,4 +59,4 @@ internal actual fun getPermissionHandler(): PermissionHandler { return remember { IosPermissionHandler() } -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt index 4b94432..185dc7a 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt @@ -13,8 +13,8 @@ fun dispatchAsync(priority: Int = DISPATCH_QUEUE_PRIORITY_HIGH, block: () -> Uni dispatch_async( queue = dispatch_get_global_queue( identifier = priority.toLong(), - flags = 0u + flags = 0u, ), - block = block + block = block, ) -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt index 49ca307..fe5236b 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt @@ -16,7 +16,7 @@ import platform.UIKit.UIViewController */ @Composable internal actual fun rememberCameraScanLauncher( - onResult: (ScannerEvent) -> Unit + onResult: (ScannerEvent) -> Unit, ): CameraScanContract = remember { object : CameraScanContract { private var viewController: UIViewController? = null @@ -41,15 +41,15 @@ internal actual fun rememberCameraScanLauncher( onResult( ScannerEvent.ScanResult( value = it, - scanKey = scanKey - ) + scanKey = scanKey, + ), ) dismiss() }, onDismiss = { onResult(ScannerEvent.Camera.Canceled) dismiss() - } + }, ) } @@ -70,4 +70,4 @@ internal actual fun rememberCameraScanLauncher( } } } -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index 29ef7ff..79684a9 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -3,13 +3,10 @@ package de.tillhub.scanengine.camera.ui import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.UIKitViewController import de.tillhub.scanengine.camera.CameraController -import de.tillhub.scanengine.data.ScannerEvent -import kotlinx.coroutines.flow.MutableStateFlow import platform.Foundation.NSNotificationCenter import platform.UIKit.UIDeviceOrientationDidChangeNotification @@ -36,12 +33,12 @@ import platform.UIKit.UIDeviceOrientationDidChangeNotification internal actual fun cameraPreview( modifier: Modifier, barcodeScanned: (String) -> Unit, - onCameraError: (String) -> Unit + onCameraError: (String) -> Unit, ) { val cameraController = remember { CameraController( barcodeScanned = barcodeScanned, - onCameraError = onCameraError + onCameraError = onCameraError, ) } @@ -50,7 +47,7 @@ internal actual fun cameraPreview( val observer = notificationCenter.addObserverForName( UIDeviceOrientationDidChangeNotification, null, - null + null, ) { _ -> cameraController.getCameraPreviewLayer()?.connection?.videoOrientation = cameraController.currentVideoOrientation() @@ -65,4 +62,4 @@ internal actual fun cameraPreview( modifier = Modifier.fillMaxSize(), factory = { cameraController }, ) -} \ No newline at end of file +} From 866437d4a97d502479c50d834aafeb8521e3f3a4 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 10 Jul 2025 14:53:38 +0200 Subject: [PATCH 13/19] [UNTIL-14107] setup unit testing, wrote some IOS unit tests --- buildSrc/settings.gradle.kts | 6 + gradle.properties | 4 +- gradle/libs.versions.toml | 5 +- scan_engine/build.gradle.kts | 18 +- .../camera/CameraController.android.kt | 5 +- .../scanengine/camera/CameraController.kt | 2 +- .../scanengine/camera/ui/CameraScreenTest.kt | 1 + .../scanengine/camera/CameraController.ios.kt | 83 ++++---- .../scanengine/camera/CameraWrapper.kt | 170 ++++++++++------ .../scanengine/camera/common/CaptureDevice.kt | 25 +++ .../camera/common/CaptureMetadataOutput.kt | 29 +++ .../camera/common/CaptureProvider.kt | 43 ++++ .../camera/common/CaptureSession.kt | 53 +++++ .../camera/common/CaptureVideoPreviewLayer.kt | 43 ++++ .../scanengine/camera/common/DispatchAsync.kt | 32 +-- .../scanengine/camera/ui/CameraScreen.ios.kt | 7 +- .../scanengine/camera/CameraControllerTest.kt | 151 ++++++++++++++ .../scanengine/camera/CameraWrapperTest.kt | 184 ++++++++++++++++++ settings.gradle.kts | 1 + 19 files changed, 727 insertions(+), 135 deletions(-) create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt create mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt create mode 100644 scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt create mode 100644 scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index c7c3c5d..f0f8d34 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -2,5 +2,11 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven("https://plugins.gradle.org/m2/") + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } } } diff --git a/gradle.properties b/gradle.properties index 7f53ad4..55afba4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,4 +8,6 @@ kotlin.code.style=official #Android android.useAndroidX=true -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true + +kotlin.native.cacheKind=none \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4bee0c5..4babdfd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -agp = "8.10.1" kotlin = "2.1.21" +agp = "8.10.1" compose = "1.8.1" atomicfu = "0.28.0" @@ -17,7 +17,7 @@ lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" spotless = "6.21.0" - +mokkery = "2.8.0" uiTestJunit4 = "1.8.3" @@ -53,6 +53,7 @@ atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +mokkery = { id = "dev.mokkery", version.ref = "mokkery" } [bundles] core = [ diff --git a/scan_engine/build.gradle.kts b/scan_engine/build.gradle.kts index 0e5da42..543071f 100644 --- a/scan_engine/build.gradle.kts +++ b/scan_engine/build.gradle.kts @@ -1,3 +1,4 @@ +import dev.mokkery.gradle.ApplicationRule import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree @@ -7,6 +8,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) + alias(libs.plugins.mokkery) } kotlin { @@ -44,6 +46,12 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) } + androidMain.dependencies { + implementation(libs.bundles.camera) + implementation(libs.bundles.mlkit) + implementation(libs.activity.compose) + } + commonTest.dependencies { implementation(kotlin("test")) @@ -56,14 +64,14 @@ kotlin { @OptIn(ExperimentalComposeLibrary::class) implementation(compose.uiTest) } - androidMain.dependencies { - implementation(libs.bundles.camera) - implementation(libs.bundles.mlkit) - implementation(libs.activity.compose) - } + } } +mokkery { + rule.set(ApplicationRule.MatchesName(Regex(".+Test"))) +} + android { namespace = Configs.APPLICATION_ID compileSdk = Configs.COMPILE_SDK diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt index 421606c..536af10 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -36,20 +36,19 @@ import com.google.mlkit.vision.common.InputImage * @property scanner The [BarcodeScanner] instance used for detecting barcodes. By default, it's * configured to scan all barcode formats. */ -actual class CameraController( +internal actual class CameraController( private val context: Context, private val lifecycleOwner: LifecycleOwner, barcodeScanned: (String) -> Unit, scanner: BarcodeScanner = BarcodeScanning.getClient( BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build(), ), -) { - private val analyzer: ImageAnalysis.Analyzer = QRImageAnalyzer( scanner = scanner, inputImageGenerator = InputImageGenerator(), barcodeScanned = barcodeScanned, ) +) { private var cameraProvider: ProcessCameraProvider? = null private var preview: Preview? = null diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt index 50a2d68..85092bf 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt +++ b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt @@ -7,7 +7,7 @@ package de.tillhub.scanengine.camera * It is an expect class, meaning that the actual implementation will be provided * by the platform-specific modules (e.g., Android, iOS). */ -expect class CameraController { +internal expect class CameraController { /** * Starts the camera session. */ diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt index 8813cd9..a58beec 100644 --- a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt +++ b/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index e8af761..f8aeb85 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -1,6 +1,9 @@ package de.tillhub.scanengine.camera -import de.tillhub.scanengine.camera.common.dispatchAsync +import de.tillhub.scanengine.camera.common.CaptureMetadataOutput +import de.tillhub.scanengine.camera.common.CaptureMetadataOutputImpl +import de.tillhub.scanengine.camera.common.Dispatcher +import de.tillhub.scanengine.camera.common.DispatcherImpl import kotlinx.atomicfu.atomic import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CoroutineScope @@ -12,7 +15,6 @@ import platform.AVFoundation.AVCaptureConnection import platform.AVFoundation.AVCaptureMetadataOutput import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol import platform.AVFoundation.AVCaptureOutput -import platform.AVFoundation.AVCaptureVideoOrientation import platform.AVFoundation.AVMetadataMachineReadableCodeObject import platform.AVFoundation.AVMetadataObjectTypeAztecCode import platform.AVFoundation.AVMetadataObjectTypeCode128Code @@ -42,13 +44,16 @@ import platform.darwin.dispatch_get_main_queue * @param barcodeScanned A lambda function that is invoked when a barcode is successfully scanned. * It receives the scanned barcode value as a [String]. */ -actual class CameraController( - private val cameraWrapper: CameraWrapper = CameraWrapper(), - private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput(), +@OptIn(ExperimentalForeignApi::class) +internal actual class CameraController( + private val cameraWrapper: CameraWrapper = CameraWrapperImpl(), + private val metadataOutput: CaptureMetadataOutput = CaptureMetadataOutputImpl(), private val onCameraError: (String) -> Unit, barcodeScanned: (String) -> Unit, + private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned), + private val dispatcher: Dispatcher = DispatcherImpl ) : UIViewController(null, null) { - private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned) + /** * Called after the controller's view is loaded into memory. @@ -67,20 +72,20 @@ actual class CameraController( @OptIn(ExperimentalForeignApi::class) override fun viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - cameraWrapper.cameraPreviewLayer?.setFrame(view.bounds) - dispatchAsync { + cameraWrapper.setPreviewLayerFrame(view) + dispatcher.dispatchAsync { setupScanner() } } /** - * Provides access to the camera preview layer. - * This layer is responsible for displaying the camera feed. - * - * @return The [platform.AVFoundation.AVCaptureVideoPreviewLayer] used for displaying the camera preview, - * or null if it's not available. + * Updates the video orientation of the camera preview layer's connection. + * This method ensures that the displayed camera feed is correctly oriented + * according to the device's current orientation by fetching the current + * video orientation from the `cameraController` and applying it to the + * preview layer's connection. */ - internal fun getCameraPreviewLayer() = cameraWrapper.cameraPreviewLayer + internal fun updateOrientation() = cameraWrapper.updateOrientation() /** * Starts the camera session. @@ -96,16 +101,6 @@ actual class CameraController( cameraWrapper.stopSession() } - /** - * Retrieves the current video orientation from the camera wrapper. - * This is important for ensuring that the displayed video and scanned barcodes - * are correctly oriented according to the device's current orientation. - * - * @return The current [AVCaptureVideoOrientation] of the camera. - */ - internal fun currentVideoOrientation(): AVCaptureVideoOrientation = - cameraWrapper.currentVideoOrientation() - /** * Sets up the camera by configuring the camera wrapper and metadata output. * This function initializes the error handling for the camera, sets up the camera session @@ -127,10 +122,7 @@ actual class CameraController( cameraWrapper.setupSession() cameraWrapper.setupPreviewLayer(view) - - if (cameraWrapper.captureSession?.canAddOutput(metadataOutput) == true) { - cameraWrapper.captureSession?.addOutput(metadataOutput) - } + cameraWrapper.addOutputIfPossible(metadataOutput) startSession() } @@ -158,22 +150,27 @@ actual class CameraController( * - UPC-E */ private fun setupScanner() { - metadataOutput.setMetadataObjectsDelegate(analyzer, dispatch_get_main_queue()) + metadataOutput.setMetadataObjectsDelegate( + delegate = analyzer, + queue = dispatch_get_main_queue() + ) - if (cameraWrapper.captureSession?.isRunning() == true) { - metadataOutput.metadataObjectTypes += listOf( - AVMetadataObjectTypeQRCode!!, - AVMetadataObjectTypeEAN13Code!!, - AVMetadataObjectTypeEAN8Code!!, - AVMetadataObjectTypeCode128Code!!, - AVMetadataObjectTypeCode39Code!!, - AVMetadataObjectTypeCode93Code!!, - AVMetadataObjectTypeCode39Mod43Code!!, - AVMetadataObjectTypeITF14Code!!, - AVMetadataObjectTypePDF417Code!!, - AVMetadataObjectTypeAztecCode!!, - AVMetadataObjectTypeDataMatrixCode!!, - AVMetadataObjectTypeUPCECode!!, + if (cameraWrapper.isRunning() == true) { + metadataOutput.addMetadataObjectTypes( + types = listOf( + AVMetadataObjectTypeQRCode!!, + AVMetadataObjectTypeEAN13Code!!, + AVMetadataObjectTypeEAN8Code!!, + AVMetadataObjectTypeCode128Code!!, + AVMetadataObjectTypeCode39Code!!, + AVMetadataObjectTypeCode93Code!!, + AVMetadataObjectTypeCode39Mod43Code!!, + AVMetadataObjectTypeITF14Code!!, + AVMetadataObjectTypePDF417Code!!, + AVMetadataObjectTypeAztecCode!!, + AVMetadataObjectTypeDataMatrixCode!!, + AVMetadataObjectTypeUPCECode!!, + ) ) } } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt index ba50ba1..e787cc3 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt @@ -1,28 +1,39 @@ package de.tillhub.scanengine.camera -import de.tillhub.scanengine.camera.common.dispatchAsync +import de.tillhub.scanengine.camera.CameraWrapperImpl.CameraException +import de.tillhub.scanengine.camera.common.CaptureMetadataOutput +import de.tillhub.scanengine.camera.common.CaptureProvider +import de.tillhub.scanengine.camera.common.CaptureProviderImpl +import de.tillhub.scanengine.camera.common.CaptureSession +import de.tillhub.scanengine.camera.common.CaptureVideoPreviewLayer +import de.tillhub.scanengine.camera.common.Dispatcher +import de.tillhub.scanengine.camera.common.DispatcherImpl import kotlinx.cinterop.ExperimentalForeignApi -import platform.AVFoundation.AVCaptureDevice -import platform.AVFoundation.AVCaptureDeviceDiscoverySession -import platform.AVFoundation.AVCaptureDeviceInput import platform.AVFoundation.AVCaptureDevicePositionBack -import platform.AVFoundation.AVCaptureDevicePositionUnspecified -import platform.AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera -import platform.AVFoundation.AVCaptureSession import platform.AVFoundation.AVCaptureSessionPresetPhoto import platform.AVFoundation.AVCaptureVideoOrientation import platform.AVFoundation.AVCaptureVideoOrientationLandscapeLeft import platform.AVFoundation.AVCaptureVideoOrientationLandscapeRight import platform.AVFoundation.AVCaptureVideoOrientationPortrait import platform.AVFoundation.AVCaptureVideoOrientationPortraitUpsideDown -import platform.AVFoundation.AVCaptureVideoPreviewLayer -import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill -import platform.AVFoundation.AVMediaTypeVideo -import platform.AVFoundation.position import platform.UIKit.UIDevice import platform.UIKit.UIDeviceOrientation import platform.UIKit.UIView -import platform.darwin.NSObject + + +@ExperimentalForeignApi +internal interface CameraWrapper { + var onError: ((CameraException) -> Unit)? + + fun setupSession() + fun startSession() + fun stopSession() + fun setupPreviewLayer(view: UIView) + fun setPreviewLayerFrame(view: UIView) + fun updateOrientation() + fun isRunning(): Boolean? + fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean? +} /** * A wrapper class for managing camera operations on iOS devices using AVFoundation. @@ -40,12 +51,15 @@ import platform.darwin.NSObject * @property cameraPreviewLayer The `AVCaptureVideoPreviewLayer` used to display the camera feed, if set up. * @property onError A callback function that is invoked when a [CameraException] occurs. */ -class CameraWrapper : NSObject() { - private var currentCamera: AVCaptureDevice? = null - var captureSession: AVCaptureSession? = null - var cameraPreviewLayer: AVCaptureVideoPreviewLayer? = null +@OptIn(ExperimentalForeignApi::class) +internal class CameraWrapperImpl( + private val captureFactory: CaptureProvider = CaptureProviderImpl, + private val dispatcher: Dispatcher = DispatcherImpl +) : CameraWrapper { + override var onError: ((CameraException) -> Unit)? = null - var onError: ((CameraException) -> Unit)? = null + private var cameraPreviewLayer: CaptureVideoPreviewLayer? = null + private var captureSession: CaptureSession? = null sealed class CameraException : Exception() { class DeviceNotAvailable : CameraException() @@ -60,9 +74,9 @@ class CameraWrapper : NSObject() { * If any [CameraException] occurs during setup, the session is cleaned up using [cleanupSession], * and the [onError] callback is invoked with the exception. */ - internal fun setupSession() { + override fun setupSession() { try { - captureSession = AVCaptureSession() + captureSession = captureFactory.getCaptureSession() captureSession?.beginConfiguration() captureSession?.sessionPreset = AVCaptureSessionPresetPhoto @@ -82,9 +96,9 @@ class CameraWrapper : NSObject() { * Starts the camera capture session if it is not already running. * The session is started asynchronously on a separate dispatch queue. */ - internal fun startSession() { + override fun startSession() { if (captureSession?.isRunning() == false) { - dispatchAsync { + dispatcher.dispatchAsync { captureSession?.startRunning() } } @@ -93,7 +107,7 @@ class CameraWrapper : NSObject() { /** * Stops the camera capture session if it is currently running. */ - internal fun stopSession() { + override fun stopSession() { if (captureSession?.isRunning() == true) { captureSession?.stopRunning() } @@ -109,19 +123,77 @@ class CameraWrapper : NSObject() { * * @param view The `UIView` on which the camera preview will be displayed. */ - @OptIn(ExperimentalForeignApi::class) - internal fun setupPreviewLayer(view: UIView) { + override fun setupPreviewLayer(view: UIView) { captureSession?.let { session -> - val newPreviewLayer = AVCaptureVideoPreviewLayer(session = session).apply { - videoGravity = AVLayerVideoGravityResizeAspectFill - setFrame(view.bounds) - connection?.videoOrientation = currentVideoOrientation() - } + val newPreviewLayer = captureFactory.getCaptureVideoPreviewLayer( + session = session, + view = view, + orientation = currentVideoOrientation(), + ) - view.layer.addSublayer(newPreviewLayer) cameraPreviewLayer = newPreviewLayer } } + + /** + * Sets the frame of the camera preview layer to match the bounds of a given UIView. + * + * This function is a convenience wrapper around [setPreviewLayerFrame] that takes a `UIView` + * as input and uses its `bounds` to update the frame of the `cameraPreviewLayer`. + * This is useful for ensuring the preview layer correctly fills the view it's displayed in. + * + * @param view The `UIView` whose bounds will be used to set the frame of the preview layer. + */ + override fun setPreviewLayerFrame(view: UIView) { + cameraPreviewLayer?.setFrame(view) + } + + /** + * Updates the orientation of the camera preview layer to match the current device orientation. + * + * This function calls [currentVideoOrientation] to get the appropriate video orientation + * and then sets the `orientation` property of the `cameraPreviewLayer`. This ensures that + * the camera preview is displayed correctly as the device is rotated. + */ + override fun updateOrientation() { + cameraPreviewLayer?.orientation = currentVideoOrientation() + } + + /** + * Checks if the camera capture session is currently running. + * + * @return `true` if the session is running, `false` if it's not, or `null` if the + * `captureSession` is not initialized. + */ + override fun isRunning(): Boolean? = captureSession?.isRunning() + + /** + * Attempts to add a `CaptureMetadataOutput` to the current `captureSession` if possible. + * + * This function delegates the operation to the `addOutputIfPossible` method of the `captureSession`. + * It's used to add specific outputs to the session, but only if they are compatible and can be added. + * + * @param output The `CaptureMetadataOutput` to be added to the session. + * @return `true` if the output was successfully added, + * `false` if it could not be added (e.g., due to incompatibility), + * or `null` if the `captureSession` is not initialized. + */ + override fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean? = + captureSession?.addOutputIfPossible(output) + + /** + * Cleans up the camera session and related resources. + * + * This function stops the capture session, removes the preview layer from its superlayer, + * and nils out references to the preview layer, capture session, and current camera. + * This is typically called when the camera is no longer needed or in case of an error. + */ + private fun cleanupSession() { + stopSession() + cameraPreviewLayer?.removeFromSuperlayer() + cameraPreviewLayer = null + captureSession = null + } /** * Determines the appropriate `AVCaptureVideoOrientation` based on the current device orientation. @@ -136,7 +208,7 @@ class CameraWrapper : NSObject() { * * @return The `AVCaptureVideoOrientation` that corresponds to the current device orientation. */ - internal fun currentVideoOrientation(): AVCaptureVideoOrientation { + private fun currentVideoOrientation(): AVCaptureVideoOrientation { val orientation = UIDevice.currentDevice.orientation return when (orientation) { UIDeviceOrientation.UIDeviceOrientationPortrait -> AVCaptureVideoOrientationPortrait @@ -147,20 +219,6 @@ class CameraWrapper : NSObject() { } } - /** - * Cleans up the camera session and related resources. - * - * This function stops the capture session, removes the preview layer from its superlayer, - * and nils out references to the preview layer, capture session, and current camera. - * This is typically called when the camera is no longer needed or in case of an error. - */ - private fun cleanupSession() { - stopSession() - cameraPreviewLayer?.removeFromSuperlayer() - cameraPreviewLayer = null - captureSession = null - currentCamera = null - } /** * Sets up the camera inputs for the `AVCaptureSession`. @@ -178,25 +236,11 @@ class CameraWrapper : NSObject() { @Suppress("TooGenericExceptionCaught", "SwallowedException") @OptIn(ExperimentalForeignApi::class) private fun setupInputs(): Boolean { - val availableDevices = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( - listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), - AVMediaTypeVideo, - AVCaptureDevicePositionUnspecified, - ).devices - - ( - availableDevices.find { - (it as AVCaptureDevice).position == AVCaptureDevicePositionBack - } as? AVCaptureDevice - )?.let { currentCamera -> - try { - val input = AVCaptureDeviceInput.deviceInputWithDevice( - currentCamera, - null, - ) + val availableDevices = captureFactory.getCaptureDevices() - if (input != null && captureSession?.canAddInput(input) == true) { - captureSession?.addInput(input) + availableDevices.find { it.position == AVCaptureDevicePositionBack }?.let { currentCamera -> + try { + if (captureSession?.addInputIfPossible(currentCamera) == true) { return true } } catch (e: Exception) { diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt new file mode 100644 index 0000000..2d5661b --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt @@ -0,0 +1,25 @@ +package de.tillhub.scanengine.camera.common + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVCaptureDeviceInput +import platform.AVFoundation.AVCaptureDevicePosition +import platform.AVFoundation.position + +internal interface CaptureDevice { + val position: AVCaptureDevicePosition + fun getAVCaptureDeviceInput(): AVCaptureDeviceInput? +} + +class CaptureDeviceImpl( + private val avCaptureDevice: AVCaptureDevice +) : CaptureDevice { + override val position = avCaptureDevice.position + + @ExperimentalForeignApi + override fun getAVCaptureDeviceInput(): AVCaptureDeviceInput? = + AVCaptureDeviceInput.deviceInputWithDevice( + avCaptureDevice, + null, + ) +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt new file mode 100644 index 0000000..22b11ca --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt @@ -0,0 +1,29 @@ +package de.tillhub.scanengine.camera.common + +import platform.AVFoundation.AVCaptureMetadataOutput +import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol +import platform.darwin.NSObject + +internal interface CaptureMetadataOutput { + val output: AVCaptureMetadataOutput + fun setMetadataObjectsDelegate( + delegate: AVCaptureMetadataOutputObjectsDelegateProtocol, + queue: NSObject? + ) + fun addMetadataObjectTypes(types: List) +} + +internal class CaptureMetadataOutputImpl( + private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput() +) : CaptureMetadataOutput { + override val output = metadataOutput + + override fun setMetadataObjectsDelegate( + delegate: AVCaptureMetadataOutputObjectsDelegateProtocol, + queue: NSObject? + ) = metadataOutput.setMetadataObjectsDelegate(delegate, queue) + + override fun addMetadataObjectTypes(types: List) { + metadataOutput.metadataObjectTypes += types + } +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt new file mode 100644 index 0000000..ec71ed2 --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt @@ -0,0 +1,43 @@ +package de.tillhub.scanengine.camera.common + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVCaptureDeviceDiscoverySession +import platform.AVFoundation.AVCaptureDevicePositionUnspecified +import platform.AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera +import platform.AVFoundation.AVCaptureVideoOrientation +import platform.AVFoundation.AVMediaTypeVideo +import platform.UIKit.UIView + +internal interface CaptureProvider { + fun getCaptureSession(): CaptureSession + @ExperimentalForeignApi + fun getCaptureVideoPreviewLayer( + session: CaptureSession, + view: UIView, + orientation: AVCaptureVideoOrientation, + ): CaptureVideoPreviewLayer + fun getCaptureDevices(): List +} + +@ExperimentalForeignApi +internal object CaptureProviderImpl: CaptureProvider { + override fun getCaptureSession(): CaptureSession = CaptureSessionImpl() + + override fun getCaptureVideoPreviewLayer( + session: CaptureSession, + view: UIView, + orientation: AVCaptureVideoOrientation, + ): CaptureVideoPreviewLayer = CaptureVideoPreviewLayerImpl( + session = session, + view = view, + orientation = orientation, + ) + + override fun getCaptureDevices(): List = + AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( + listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), + AVMediaTypeVideo, + AVCaptureDevicePositionUnspecified, + ).devices.mapNotNull { it as? AVCaptureDevice }.map { CaptureDeviceImpl(it) } +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt new file mode 100644 index 0000000..67f3c09 --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt @@ -0,0 +1,53 @@ +package de.tillhub.scanengine.camera.common + +import platform.AVFoundation.AVCaptureSession + +internal interface CaptureSession { + var sessionPreset: String? + + fun beginConfiguration() + fun commitConfiguration() + fun isRunning(): Boolean + fun startRunning() + fun stopRunning() + fun addInputIfPossible(device: CaptureDevice): Boolean + fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean + fun canAddOutput(output: CaptureMetadataOutput): Boolean + fun addOutput(output: CaptureMetadataOutput) + fun getSession(): AVCaptureSession +} + +internal class CaptureSessionImpl : CaptureSession { + private val session: AVCaptureSession = AVCaptureSession() + + override var sessionPreset: String? + get() = session.sessionPreset + set(value) { + session.sessionPreset = value + } + + override fun beginConfiguration() = session.beginConfiguration() + override fun commitConfiguration() = session.commitConfiguration() + override fun isRunning() = session.isRunning() + override fun startRunning() = session.startRunning() + override fun stopRunning() = session.stopRunning() + override fun addInputIfPossible(device: CaptureDevice): Boolean { + val input = device.getAVCaptureDeviceInput() + + if (input != null && session.canAddInput(input)) { + session.addInput(input) + return true + } + return false + } + override fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean { + if (session.canAddOutput(output.output)) { + session.addOutput(output.output) + return true + } + return false + } + override fun canAddOutput(output: CaptureMetadataOutput) = session.canAddOutput(output.output) + override fun addOutput(output: CaptureMetadataOutput) = session.addOutput(output.output) + override fun getSession() = session +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt new file mode 100644 index 0000000..cfd99b0 --- /dev/null +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt @@ -0,0 +1,43 @@ +package de.tillhub.scanengine.camera.common + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVCaptureVideoOrientation +import platform.AVFoundation.AVCaptureVideoPreviewLayer +import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill +import platform.UIKit.UIView + +internal interface CaptureVideoPreviewLayer { + var orientation: Long? + fun setFrame(view: UIView) + fun removeFromSuperlayer() +} + +@ExperimentalForeignApi +internal class CaptureVideoPreviewLayerImpl( + session: CaptureSession, + view: UIView, + orientation: AVCaptureVideoOrientation +) : CaptureVideoPreviewLayer { + private val avCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer( + session = session.getSession() + ).apply { + videoGravity = AVLayerVideoGravityResizeAspectFill + setFrame(view.bounds) + connection?.videoOrientation = orientation + + view.layer.addSublayer(this) + } + + override var orientation: Long? + get() = avCaptureVideoPreviewLayer.connection?.videoOrientation + set(value) { + value ?: return + avCaptureVideoPreviewLayer.connection?.videoOrientation = value + } + + override fun setFrame(view: UIView) { + avCaptureVideoPreviewLayer.setFrame(view.bounds) + } + + override fun removeFromSuperlayer() = avCaptureVideoPreviewLayer.removeFromSuperlayer() +} \ No newline at end of file diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt index 185dc7a..5e51ade 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt @@ -4,17 +4,23 @@ import platform.darwin.DISPATCH_QUEUE_PRIORITY_HIGH import platform.darwin.dispatch_async import platform.darwin.dispatch_get_global_queue -/** - * Dispatch a block on the main queue. - * @param block The block to dispatch. - * @param priority The priority of the dispatch queue. - */ -fun dispatchAsync(priority: Int = DISPATCH_QUEUE_PRIORITY_HIGH, block: () -> Unit) { - dispatch_async( - queue = dispatch_get_global_queue( - identifier = priority.toLong(), - flags = 0u, - ), - block = block, - ) +internal interface Dispatcher { + fun dispatchAsync(priority: Int = DISPATCH_QUEUE_PRIORITY_HIGH, block: () -> Unit) +} + +internal object DispatcherImpl : Dispatcher { + /** + * Dispatch a block on the main queue. + * @param block The block to dispatch. + * @param priority The priority of the dispatch queue. + */ + override fun dispatchAsync(priority: Int, block: () -> Unit) { + dispatch_async( + queue = dispatch_get_global_queue( + identifier = priority.toLong(), + flags = 0u, + ), + block = block, + ) + } } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index 79684a9..623dbb5 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.UIKitViewController import de.tillhub.scanengine.camera.CameraController +import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSNotificationCenter import platform.UIKit.UIDeviceOrientationDidChangeNotification @@ -29,6 +30,7 @@ import platform.UIKit.UIDeviceOrientationDidChangeNotification * @param onCameraError A lambda function that is invoked if an error occurs during camera initialization * or operation. It receives an error message string as a parameter. */ +@OptIn(ExperimentalForeignApi::class) @Composable internal actual fun cameraPreview( modifier: Modifier, @@ -48,10 +50,7 @@ internal actual fun cameraPreview( UIDeviceOrientationDidChangeNotification, null, null, - ) { _ -> - cameraController.getCameraPreviewLayer()?.connection?.videoOrientation = - cameraController.currentVideoOrientation() - } + ) { _ -> cameraController.updateOrientation() } onDispose { notificationCenter.removeObserver(observer) diff --git a/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt b/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt new file mode 100644 index 0000000..c54a2d6 --- /dev/null +++ b/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt @@ -0,0 +1,151 @@ +package de.tillhub.scanengine.camera + +import de.tillhub.scanengine.camera.common.CaptureMetadataOutput +import de.tillhub.scanengine.camera.common.CaptureSession +import de.tillhub.scanengine.camera.common.Dispatcher +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVMetadataObjectTypeAztecCode +import platform.AVFoundation.AVMetadataObjectTypeCode128Code +import platform.AVFoundation.AVMetadataObjectTypeCode39Code +import platform.AVFoundation.AVMetadataObjectTypeCode39Mod43Code +import platform.AVFoundation.AVMetadataObjectTypeCode93Code +import platform.AVFoundation.AVMetadataObjectTypeDataMatrixCode +import platform.AVFoundation.AVMetadataObjectTypeEAN13Code +import platform.AVFoundation.AVMetadataObjectTypeEAN8Code +import platform.AVFoundation.AVMetadataObjectTypeITF14Code +import platform.AVFoundation.AVMetadataObjectTypePDF417Code +import platform.AVFoundation.AVMetadataObjectTypeQRCode +import platform.AVFoundation.AVMetadataObjectTypeUPCECode +import kotlin.test.BeforeTest +import kotlin.test.Test + +@ExperimentalForeignApi +internal class CameraControllerTest { + lateinit var cameraWrapper: CameraWrapper + lateinit var metadataOutput: CaptureMetadataOutput + lateinit var analyzer: QRImageAnalyzer + + val dispatcher = object : Dispatcher { + override fun dispatchAsync(priority: Int, block: () -> Unit) { + block() + } + } + + lateinit var callbacks: Callbacks + + lateinit var target: CameraController + + @BeforeTest + fun setup() { + metadataOutput = mock { + every { setMetadataObjectsDelegate(any(), any()) } returns Unit + every { addMetadataObjectTypes(any()) } returns Unit + } + + cameraWrapper = mock { + every { setPreviewLayerFrame(any()) } returns Unit + every { addOutputIfPossible(any()) } returns true + every { setupPreviewLayer(any()) } returns Unit + every { startSession() } returns Unit + every { stopSession() } returns Unit + every { setupSession() } returns Unit + every { updateOrientation() } returns Unit + every { isRunning() } returns true + every { onError = any() } returns Unit + } + callbacks = mock { + every { onBarcodeScanned(any()) } returns Unit + every { onCameraError(any()) } returns Unit + } + analyzer = QRImageAnalyzer(callbacks::onBarcodeScanned) + + target = CameraController( + cameraWrapper = cameraWrapper, + metadataOutput = metadataOutput, + onCameraError = callbacks::onCameraError, + barcodeScanned = callbacks::onBarcodeScanned, + analyzer = analyzer, + dispatcher = dispatcher + ) + } + + @Test + fun testViewDidLoad() { + target.viewDidLoad() + + verify { + cameraWrapper.onError = any() + cameraWrapper.setupSession() + cameraWrapper.setupPreviewLayer(any()) + cameraWrapper.addOutputIfPossible(metadataOutput) + cameraWrapper.startSession() + } + } + + @Test + fun testViewDidLayoutSubviews() { + target.viewDidLayoutSubviews() + + verify { + cameraWrapper.setPreviewLayerFrame(any()) + metadataOutput.setMetadataObjectsDelegate( + delegate = any(), + queue = any() + ) + cameraWrapper.isRunning() + metadataOutput.addMetadataObjectTypes( + types = listOf( + AVMetadataObjectTypeQRCode!!, + AVMetadataObjectTypeEAN13Code!!, + AVMetadataObjectTypeEAN8Code!!, + AVMetadataObjectTypeCode128Code!!, + AVMetadataObjectTypeCode39Code!!, + AVMetadataObjectTypeCode93Code!!, + AVMetadataObjectTypeCode39Mod43Code!!, + AVMetadataObjectTypeITF14Code!!, + AVMetadataObjectTypePDF417Code!!, + AVMetadataObjectTypeAztecCode!!, + AVMetadataObjectTypeDataMatrixCode!!, + AVMetadataObjectTypeUPCECode!!, + ) + ) + } + } + + @Test + fun testUpdateOrientation() { + target.updateOrientation() + + verify { + cameraWrapper.updateOrientation() + } + } + + @Test + fun testStartSession() { + target.startSession() + + verify { + cameraWrapper.startSession() + } + } + + @Test + fun testStopSession() { + target.stopSession() + + verify { + cameraWrapper.stopSession() + } + } + + interface Callbacks { + fun onBarcodeScanned(barcode: String) + fun onCameraError(error: String) + } +} \ No newline at end of file diff --git a/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt b/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt new file mode 100644 index 0000000..6afe9b9 --- /dev/null +++ b/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt @@ -0,0 +1,184 @@ +package de.tillhub.scanengine.camera + +import de.tillhub.scanengine.camera.common.CaptureDevice +import de.tillhub.scanengine.camera.common.CaptureMetadataOutput +import de.tillhub.scanengine.camera.common.CaptureProvider +import de.tillhub.scanengine.camera.common.CaptureSession +import de.tillhub.scanengine.camera.common.CaptureVideoPreviewLayer +import de.tillhub.scanengine.camera.common.Dispatcher +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFoundation.AVCaptureDevicePositionBack +import platform.AVFoundation.AVCaptureSession +import platform.AVFoundation.AVCaptureSessionPresetPhoto +import platform.AVFoundation.AVCaptureVideoOrientationPortrait +import platform.UIKit.UIView +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalForeignApi::class) +internal class CameraWrapperTest { + private lateinit var captureSession: CaptureSession + private lateinit var captureVideoPreviewLayer: CaptureVideoPreviewLayer + private lateinit var captureDevice: CaptureDevice + + private lateinit var captureProvider: CaptureProvider + + val dispatcher = object : Dispatcher { + override fun dispatchAsync(priority: Int, block: () -> Unit) { + block() + } + } + + private lateinit var target: CameraWrapper + + @BeforeTest + fun setup() { + captureSession = mock { + every { sessionPreset = any() } returns Unit + every { beginConfiguration() } returns Unit + every { commitConfiguration() } returns Unit + every { isRunning() } returns true + every { startRunning() } returns Unit + every { stopRunning() } returns Unit + every { addInputIfPossible(any()) } returns true + every { addOutputIfPossible(any()) } returns true + every { canAddOutput(any()) } returns true + every { addOutput(any()) } returns Unit + every { getSession() } returns AVCaptureSession() + } + captureVideoPreviewLayer = mock { + every { orientation } returns AVCaptureVideoOrientationPortrait + every { orientation = any() } returns Unit + every { setFrame(any()) } returns Unit + every { removeFromSuperlayer() } returns Unit + } + captureDevice = mock { + every { position } returns AVCaptureDevicePositionBack + } + + captureProvider = mock { + every { getCaptureSession() } returns captureSession + every { getCaptureVideoPreviewLayer(any(), any(), any()) } returns captureVideoPreviewLayer + every { getCaptureDevices() } returns listOf(captureDevice) + } + + target = CameraWrapperImpl( + captureFactory = captureProvider, + dispatcher = dispatcher + ) + } + + @Test + fun testSetupSession() { + target.setupSession() + + verify { + captureProvider.getCaptureSession() + captureSession.beginConfiguration() + captureSession.sessionPreset = AVCaptureSessionPresetPhoto + captureProvider.getCaptureDevices() + captureDevice.position + captureSession.addInputIfPossible(any()) + } + } + + @Test + fun testStartSession() { + target.setupSession() + every { captureSession.isRunning() } returns false + target.startSession() + + verify { + captureSession.isRunning() + captureSession.startRunning() + } + } + + @Test + fun testStopSession() { + target.setupSession() + target.stopSession() + + verify { + captureSession.isRunning() + captureSession.stopRunning() + } + } + + @Test + fun testSetupPreviewLayer() { + target.setupSession() + + val view = UIView() + + target.setupPreviewLayer(view) + + verify { + captureProvider.getCaptureVideoPreviewLayer( + session = captureSession, + view = view, + orientation = AVCaptureVideoOrientationPortrait, + ) + } + } + + @Test + fun testSetPreviewLayerFrame() { + target.setupSession() + val view = UIView() + target.setupPreviewLayer(view) + + target.setPreviewLayerFrame(view) + + verify { + captureVideoPreviewLayer.setFrame(view) + } + } + + @Test + fun testUpdateOrientation() { + target.setupSession() + val view = UIView() + target.setupPreviewLayer(view) + + target.updateOrientation() + + verify { + captureVideoPreviewLayer.orientation = AVCaptureVideoOrientationPortrait + } + } + + @Test + fun testIsRunning() { + target.setupSession() + val result = target.isRunning() + + assertNotNull(result) + assertTrue(result) + + verify { + captureSession.isRunning() + } + } + + @Test + fun testAddOutputIfPossible() { + target.setupSession() + val output = mock() + val result = target.addOutputIfPossible(output) + + assertNotNull(result) + assertTrue(result) + + verify { + captureSession.addOutputIfPossible(output) + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 1472d2a..187af73 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ pluginManagement { dependencyResolutionManagement { repositories { google() + gradlePluginPortal() mavenCentral() maven("https://plugins.gradle.org/m2/") } From 291a3d9dfa09e39d8726166b73835112ae922f65 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 16 Jul 2025 13:12:10 +0200 Subject: [PATCH 14/19] [UNTIL-14107] added some android implementation tests --- gradle/libs.versions.toml | 4 + scan_engine/build.gradle.kts | 5 +- .../camera/CameraController.android.kt | 70 ++++++------ .../scanengine/camera/CameraHandler.kt | 24 ++++ .../camera/common/CameraProvider.kt | 30 +++++ .../scanengine/camera/CameraControllerTest.kt | 106 ++++++++++++++++++ 6 files changed, 202 insertions(+), 37 deletions(-) create mode 100644 scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt create mode 100644 scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt create mode 100644 scan_engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4babdfd..0c800bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,9 +20,13 @@ spotless = "6.21.0" mokkery = "2.8.0" uiTestJunit4 = "1.8.3" +robolectric = "4.15" +androidx-test = "1.6.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidx-test" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera-core" } camera-core = { module = "androidx.camera:camera-core", version.ref = "camera-core" } diff --git a/scan_engine/build.gradle.kts b/scan_engine/build.gradle.kts index 543071f..f8750ac 100644 --- a/scan_engine/build.gradle.kts +++ b/scan_engine/build.gradle.kts @@ -24,6 +24,9 @@ kotlin { dependencies { androidTestImplementation(libs.androidx.ui.test.junit4.android) androidTestImplementation(libs.androidx.ui.test.manifest) + + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) } } @@ -69,7 +72,7 @@ kotlin { } mokkery { - rule.set(ApplicationRule.MatchesName(Regex(".+Test"))) + rule.set(ApplicationRule.AllTests) } android { diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt index 536af10..5d9d671 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -10,7 +10,6 @@ import androidx.camera.core.Preview import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy -import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner @@ -19,6 +18,8 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage +import de.tillhub.scanengine.camera.common.CameraProvider +import java.util.concurrent.Executor /** * Manages the camera operations for barcode scanning. @@ -36,6 +37,7 @@ import com.google.mlkit.vision.common.InputImage * @property scanner The [BarcodeScanner] instance used for detecting barcodes. By default, it's * configured to scan all barcode formats. */ +@Suppress("LongParameterList") internal actual class CameraController( private val context: Context, private val lifecycleOwner: LifecycleOwner, @@ -47,16 +49,16 @@ internal actual class CameraController( scanner = scanner, inputImageGenerator = InputImageGenerator(), barcodeScanned = barcodeScanned, - ) + ), + private val executor: Executor = ContextCompat.getMainExecutor(context), + private val cameraHandler: CameraHandler = CameraHandlerImpl(context, executor) ) { - private var cameraProvider: ProcessCameraProvider? = null + private var cameraProvider: CameraProvider? = null private var preview: Preview? = null private var camera: Camera? = null private var previewView: PreviewView? = null - private val executor = ContextCompat.getMainExecutor(context) - /** * Binds the camera to the provided [PreviewView] and starts the camera session. * @@ -71,37 +73,33 @@ internal actual class CameraController( internal fun bindCamera(previewView: PreviewView, onCameraReady: () -> Unit = {}) { this.previewView = previewView - val cameraProviderFuture = ProcessCameraProvider.getInstance(context) - cameraProviderFuture.addListener( - { - cameraProvider = cameraProviderFuture.get() - cameraProvider?.unbindAll() - - preview = Preview.Builder() - .setResolutionSelector(createResolutionSelector()) - .build() - .also { - it.surfaceProvider = previewView.surfaceProvider - } - - val imageAnalyzer = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { - it.setAnalyzer(executor, analyzer) - } - - camera = cameraProvider?.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageAnalyzer, - ) - - onCameraReady() - }, - executor, - ) + cameraHandler.getCameraProvider { provider -> + cameraProvider = provider + cameraProvider?.unbindAll() + + preview = Preview.Builder() + .setResolutionSelector(createResolutionSelector()) + .build() + .also { + it.surfaceProvider = previewView.surfaceProvider + } + + val imageAnalyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer(executor, analyzer) + } + + camera = cameraProvider?.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer, + ) + + onCameraReady() + } } /** diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt new file mode 100644 index 0000000..fdd803c --- /dev/null +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt @@ -0,0 +1,24 @@ +package de.tillhub.scanengine.camera + +import android.content.Context +import androidx.camera.lifecycle.ProcessCameraProvider +import de.tillhub.scanengine.camera.common.CameraProvider +import de.tillhub.scanengine.camera.common.CameraProviderImpl +import java.util.concurrent.Executor + +internal interface CameraHandler { + fun getCameraProvider(callback: (cameraProvider: CameraProvider) -> Unit) +} + +internal class CameraHandlerImpl( + private val context: Context, + private val executor: Executor +) : CameraHandler{ + override fun getCameraProvider(callback: (cameraProvider: CameraProvider) -> Unit) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener( + { callback(CameraProviderImpl(cameraProviderFuture.get())) }, + executor, + ) + } +} \ No newline at end of file diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt new file mode 100644 index 0000000..81b6ab7 --- /dev/null +++ b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt @@ -0,0 +1,30 @@ +package de.tillhub.scanengine.camera.common + +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.UseCase +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.lifecycle.LifecycleOwner + +internal interface CameraProvider { + fun unbindAll() + fun bindToLifecycle( + lifecycleOwner: LifecycleOwner, + cameraSelector: CameraSelector, + vararg useCases: UseCase? + ): Camera +} + +internal class CameraProviderImpl( + private val cameraProvider: ProcessCameraProvider +) : CameraProvider { + override fun unbindAll() { + cameraProvider.unbindAll() + } + + override fun bindToLifecycle( + lifecycleOwner: LifecycleOwner, + cameraSelector: CameraSelector, + vararg useCases: UseCase? + ): Camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, *useCases) +} \ No newline at end of file diff --git a/scan_engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt b/scan_engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt new file mode 100644 index 0000000..d57c1bd --- /dev/null +++ b/scan_engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt @@ -0,0 +1,106 @@ +package de.tillhub.scanengine.camera + +import android.content.Context +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.view.PreviewView +import androidx.lifecycle.LifecycleOwner +import androidx.test.core.app.ApplicationProvider +import com.google.mlkit.vision.barcode.BarcodeScanner +import de.tillhub.scanengine.camera.common.CameraProvider +import dev.mokkery.answering.calls +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.Executor +import kotlin.test.BeforeTest +import kotlin.test.Test + +@RunWith(RobolectricTestRunner::class) +internal class CameraControllerTest { + lateinit var context: Context + lateinit var lifecycleOwner: LifecycleOwner + lateinit var scanner: BarcodeScanner + lateinit var analyzer: ImageAnalysis.Analyzer + lateinit var executor: Executor + lateinit var cameraHandler: CameraHandler + + lateinit var callbacks: Callbacks + lateinit var cameraProvider: CameraProvider + + lateinit var target: CameraController + + @BeforeTest + fun setup() { + cameraProvider = mock { + every { unbindAll() } returns Unit + every { bindToLifecycle(any(), any(), any(), any()) } returns mock() + } + callbacks = mock { + every { onCameraReady() } returns Unit + every { barcodeScanned(any()) } returns Unit + } + + context = ApplicationProvider.getApplicationContext() + lifecycleOwner = mock() + scanner = mock() + analyzer = mock() + executor = mock() + cameraHandler = mock { + every { getCameraProvider(any()) } calls { (callback: (CameraProvider) -> Unit) -> + callback(cameraProvider) + } + } + + target = CameraController( + context, + lifecycleOwner, + callbacks::barcodeScanned, + scanner, + analyzer, + executor, + cameraHandler + ) + } + + @Test + fun testBindCamera() { + val previewView = PreviewView(context) + target.bindCamera(previewView, callbacks::onCameraReady) + + verify { + cameraHandler.getCameraProvider(any()) + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + any(), + any(), + ) + callbacks.onCameraReady() + } + } + + @Test + fun testStopSession() { + val previewView = PreviewView(context) + target.bindCamera(previewView, callbacks::onCameraReady) + + target.stopSession() + + verify(VerifyMode.exactly(2)) { + cameraProvider.unbindAll() + } + } + + interface Callbacks { + fun barcodeScanned(barcode: String) + fun onCameraReady() + } +} \ No newline at end of file From 487e5acd18dab5e68a844c6ff763a91c70f73cf1 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 16 Jul 2025 14:33:38 +0200 Subject: [PATCH 15/19] [UNTIL-14107] fixed spotless --- sample/build.gradle.kts | 6 ++---- .../de/tillhub/scanengine/sample/MainActivity.kt | 2 +- .../kotlin/de/tillhub/scanengine/sample/App.kt | 12 ++++++------ .../de/tillhub/scanengine/sample/theme/Color.kt | 2 +- .../de/tillhub/scanengine/sample/theme/Theme.kt | 10 +++++----- .../de/tillhub/scanengine/sample/theme/Type.kt | 8 ++++---- .../tillhub/scanengine/sample/MainViewController.kt | 2 +- scan_engine/src/androidMain/AndroidManifest.xml | 3 +-- 8 files changed, 21 insertions(+), 24 deletions(-) diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 220aa5a..60cf640 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -37,8 +37,6 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(compose.ui) - - } } } @@ -62,7 +60,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } @@ -76,4 +74,4 @@ android { } dependencies { -} \ No newline at end of file +} diff --git a/sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt b/sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt index 0d00878..1230e80 100644 --- a/sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt +++ b/sample/src/androidMain/kotlin/de/tillhub/scanengine/sample/MainActivity.kt @@ -11,4 +11,4 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { App() } } -} \ No newline at end of file +} diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt index 8c764d6..ab721d2 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt @@ -59,14 +59,14 @@ fun App() { modifier = Modifier .fillMaxSize() .padding(WindowInsets.systemBars.asPaddingValues()) - .padding(16.dp) + .padding(16.dp), ) { Column { Card( modifier = Modifier .fillMaxWidth() .padding(bottom = 24.dp), - elevation = CardDefaults.cardElevation(4.dp) + elevation = CardDefaults.cardElevation(4.dp), ) { Text( modifier = Modifier @@ -76,7 +76,7 @@ fun App() { .wrapContentWidth(), text = "Results : ${scannedResult.value}", fontSize = 20.sp, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, ) } @@ -100,12 +100,12 @@ fun App() { @Composable fun ScanSection( label: String, - onClick: () -> Unit + onClick: () -> Unit, ) { Column { OutlinedButton( modifier = Modifier.fillMaxWidth(), - onClick = onClick + onClick = onClick, ) { Text(text = label) } @@ -114,4 +114,4 @@ fun ScanSection( enum class ScanScreen { CameraScan, Bluetooth -} \ No newline at end of file +} diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Color.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Color.kt index 9999178..765e188 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Color.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Color.kt @@ -8,4 +8,4 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt index 58571ca..92355f5 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Theme.kt @@ -9,25 +9,25 @@ import androidx.compose.runtime.Composable private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, - tertiary = Pink80 + tertiary = Pink80, ) private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, - tertiary = Pink40 + tertiary = Pink40, ) @Composable fun ScanEngineTheme( darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, typography = Typography, - content = content + content = content, ) -} \ No newline at end of file +} diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt index af23b8b..b8ba61c 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt @@ -13,8 +13,8 @@ val Typography = Typography( fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) + letterSpacing = 0.5.sp, + ), /* Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, @@ -30,5 +30,5 @@ val Typography = Typography( lineHeight = 16.sp, letterSpacing = 0.5.sp ) - */ -) \ No newline at end of file + */ +) diff --git a/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt b/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt index 4e5d914..7aca0be 100644 --- a/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt +++ b/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt @@ -2,4 +2,4 @@ package de.tillhub.scanengine.sample import androidx.compose.ui.window.ComposeUIViewController -fun MainViewController() = ComposeUIViewController { App() } \ No newline at end of file +fun MainViewController() = ComposeUIViewController { App() } diff --git a/scan_engine/src/androidMain/AndroidManifest.xml b/scan_engine/src/androidMain/AndroidManifest.xml index 186ad34..bcf7750 100644 --- a/scan_engine/src/androidMain/AndroidManifest.xml +++ b/scan_engine/src/androidMain/AndroidManifest.xml @@ -1,6 +1,5 @@ - + From f0fbf646ea341604c862aba6c0d57c59cecdb379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90or=C4=91e=20Hrnjez?= Date: Thu, 11 Sep 2025 10:22:50 +0200 Subject: [PATCH 16/19] Common fixed and test covered --- .claude/settings.local.json | 11 ++ .github/workflows/develop.yml | 69 ++++++++++ .github/workflows/release.yml | 122 +++++++----------- .gitignore | 2 +- CLAUDE.md | 121 +++++++++++++++++ build.gradle.kts | 7 +- gradle/libs.versions.toml | 11 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../de/tillhub/scanengine/sample/App.kt | 3 +- .../tillhub/scanengine/sample/theme/Type.kt | 24 ++-- .../scanengine/sample/MainViewController.kt | 4 + {scan_engine => scan-engine}/build.gradle.kts | 67 +++++++++- .../ui/BottomButtonInstrumentedTest.kt | 45 +++++++ .../scanengine/ui/ToolbarInstrumentedTest.kt | 70 ++++++++++ .../src/androidMain/AndroidManifest.xml | 0 .../camera/AndroidPermissionHandler.kt | 45 ++++--- .../camera/CameraController.android.kt | 106 ++++++++------- .../scanengine/camera/CameraHandler.kt | 6 +- .../camera/common/CameraProvider.kt | 11 +- .../contract/CameraScanContract.android.kt | 30 +++-- .../camera/ui/CameraScanActivity.kt | 8 +- .../camera/ui/CameraScreen.android.kt | 7 +- .../scanengine/camera/CameraControllerTest.kt | 49 +++---- .../composeResources/values-de/strings.xml | 0 .../composeResources/values/strings.xml | 0 .../de/tillhub/scanengine/BarcodeScanner.kt | 5 + .../de/tillhub/scanengine/CameraScanner.kt | 0 .../de/tillhub/scanengine/KeyEventScanner.kt | 5 +- .../de/tillhub/scanengine/ScanEngine.kt | 5 +- .../scanengine/camera/CameraController.kt | 0 .../scanengine/camera/DefaultCameraScanner.kt | 8 +- .../scanengine/camera/PermissionHandler.kt | 5 +- .../camera/contract/CameraScanContract.kt | 4 +- .../scanengine/camera/ui/CameraScreen.kt | 2 + .../scanengine/common/SingletonHolder.kt | 4 +- .../de/tillhub/scanengine/data/Scanner.kt | 0 .../tillhub/scanengine/data/ScannerEvent.kt | 11 +- .../scanengine/data/ScannerResponse.kt | 7 +- .../de/tillhub/scanengine/data/ScannerType.kt | 9 ++ .../scanengine/ui/components/BottomButton.kt | 13 +- .../scanengine/ui/components/Toolbar.kt | 0 .../de/tillhub/scanengine/ui/theme/Color.kt | 0 .../de/tillhub/scanengine/ui/theme/Style.kt | 9 +- .../de/tillhub/scanengine/ui/theme/Theme.kt | 0 .../de/tillhub/scanengine/ui/theme/Type.kt | 115 +++++++++++++++++ .../de/tillhub/scanengine/ScanEngineTest.kt | 1 - .../camera/DefaultCameraScannerTest.kt | 0 .../scanengine/camera/ui/CameraScreenTest.kt | 56 ++++---- .../scanengine/camera/CameraController.ios.kt | 51 ++++---- .../scanengine/camera/CameraWrapper.kt | 34 +++-- .../scanengine/camera/IosPermissionHandler.kt | 11 +- .../scanengine/camera/common/CaptureDevice.kt | 14 +- .../camera/common/CaptureMetadataOutput.kt | 10 +- .../camera/common/CaptureProvider.kt | 14 +- .../camera/common/CaptureSession.kt | 20 ++- .../camera/common/CaptureVideoPreviewLayer.kt | 25 ++-- .../scanengine/camera/common/DispatchAsync.kt | 13 +- .../camera/contract/CameraScanContract.ios.kt | 39 +++--- .../scanengine/camera/ui/CameraScreen.ios.kt | 24 ++-- .../scanengine/camera/CameraControllerTest.kt | 83 ++++++------ .../scanengine/camera/CameraWrapperTest.kt | 85 ++++++------ .../de/tillhub/scanengine/Platform.android.kt | 8 -- .../kotlin/de/tillhub/scanengine/Platform.kt | 8 -- .../de/tillhub/scanengine/data/ScannerType.kt | 17 --- .../de/tillhub/scanengine/ui/theme/Type.kt | 99 -------------- .../de/tillhub/scanengine/Platform.ios.kt | 10 -- settings.gradle.kts | 2 +- 67 files changed, 1052 insertions(+), 594 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .github/workflows/develop.yml create mode 100644 CLAUDE.md rename {scan_engine => scan-engine}/build.gradle.kts (62%) create mode 100644 scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt create mode 100644 scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt rename {scan_engine => scan-engine}/src/androidMain/AndroidManifest.xml (100%) rename {scan_engine => scan-engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt (75%) rename {scan_engine => scan-engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt (73%) rename {scan_engine => scan-engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt (93%) rename {scan_engine => scan-engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt (75%) rename {scan_engine => scan-engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt (78%) rename {scan_engine => scan-engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt (82%) rename {scan_engine => scan-engine}/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt (94%) rename {scan_engine => scan-engine}/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt (73%) rename {scan_engine => scan-engine}/src/commonMain/composeResources/values-de/strings.xml (100%) rename {scan_engine => scan-engine}/src/commonMain/composeResources/values/strings.xml (100%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt (99%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt (100%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt (54%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt (95%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt (100%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt (88%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt (88%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt (91%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt (98%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt (95%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt (100%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt (76%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt (76%) create mode 100644 scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt (76%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt (100%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt (100%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt (87%) rename {scan_engine => scan-engine}/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt (100%) create mode 100644 scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt rename {scan_engine => scan-engine}/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt (99%) rename {scan_engine => scan-engine}/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt (100%) rename {scan_engine => scan-engine}/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt (72%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt (90%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt (95%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt (90%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt (78%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt (92%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt (81%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt (98%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt (72%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt (69%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt (70%) rename {scan_engine => scan-engine}/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt (82%) rename {scan_engine => scan-engine}/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt (68%) rename {scan_engine => scan-engine}/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt (67%) delete mode 100644 scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt delete mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt delete mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt delete mode 100644 scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt delete mode 100644 scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6e734f9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew:*)", + "Bash(gh auth:*)", + "Bash(gh pr diff:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000..768a2c1 --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,69 @@ +name: Android CI - Develop + +on: + push: + branches: [ "develop" ] + +jobs: + ui-tests: + name: Run android tests and build staging + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + + - name: Checking out branch + uses: actions/checkout@v4 + + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + # Hardware accelerated Android virtualization on Actions Linux larger hosted runners + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # Significantly reduce emulator startup time by setting up AVD snapshot caching + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-30 + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: ReactiveCircus/android-emulator-runner@v2.33.0 + with: + api-level: 30 + target: google_atd + arch: x86 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run Android tests + uses: ReactiveCircus/android-emulator-runner@v2.33.0 + with: + api-level: 30 + target: google_atd + arch: x86 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew :scan-engine:connectedAndroidTest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28c4937..3de1260 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,77 +1,53 @@ -name: Android CI - Release +name: Publish to Maven Central on: - push: - branches: [ "master" ] + push: + branches: [ "master" ] jobs: - basic-tests: - name: Run all tests - - runs-on: ubuntu-latest - strategy: - matrix: - api-level: [ 30 ] - target: [ google_apis ] - - steps: - - - name: Checking out branch - uses: actions/checkout@v4 - - - name: set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - - - name: Run Detekt Code Analysis - run: ./gradlew detekt - - - name: Run Unit tests - run: ./gradlew testDebug - - # Hardware accelerated Android virtualization on Actions Linux larger hosted runners - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - # Significantly reduce emulator startup time by setting up AVD snapshot caching - - name: Gradle cache - uses: gradle/actions/setup-gradle@v3 - - - name: AVD cache - uses: actions/cache@v4 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ matrix.api-level }} - - - name: Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: ReactiveCircus/android-emulator-runner@v2.30.1 - with: - api-level: ${{ matrix.api-level }} - target: ${{ matrix.target }} - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." - - - name: Run Android tests - uses: ReactiveCircus/android-emulator-runner@v2.30.1 - with: - api-level: ${{ matrix.api-level }} - target: ${{ matrix.target }} - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: ./gradlew scan-engine:connectedCheck + publish: + name: Build and publish to Maven Central + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Install GPG + env: + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + run: | + brew install gpg + echo "$SIGNING_KEY" | gpg --dearmor > ${HOME}/secring.gpg + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Add Gradle Properties + env: + MAVEN_CENTRAL_USER_NAME: ${{ secrets.MAVEN_CENTRAL_USER_NAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + run: | + echo "mavenCentralUsername=${MAVEN_CENTRAL_USER_NAME}" >> gradle.properties + echo "mavenCentralPassword=${MAVEN_CENTRAL_PASSWORD}" >> gradle.properties + echo "signing.keyId=${SIGNING_KEY_ID}" >> gradle.properties + echo "signing.password=${SIGNING_KEY_PASSWORD}" >> gradle.properties + echo "signing.secretKeyRingFile=${HOME}/secring.gpg" >> gradle.properties + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Publish To Maven Central + run: | + ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache \ No newline at end of file diff --git a/.gitignore b/.gitignore index 432f9a2..cd06dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ out/ # Gradle files .gradle/ /build -/scan_engine/build +/scan-engine/build /sample/build /buildSrc/build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..97daf55 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Kotlin Multiplatform (KMP) library that provides unified barcode scanning functionality for Android and iOS platforms. The library combines various scanning protocols into a single, easy-to-use interface and is published on Maven Central. + +## Build Commands + +### Core Build Tasks +- `./gradlew build` - Full build including tests and checks +- `./gradlew assemble` - Build all variants without running tests +- `./gradlew clean` - Clean all build artifacts + +### Testing +- Test coverage is ony important for the `scan-engine/` module and run tests only for this module +- `./gradlew :scan-engine:allTests` - Run tests for all platforms (Android, iOS) and create aggregated report +- `./gradlew :scan-engine:test` - Run Android unit tests for all variants +- `./gradlew :scan-engine:testDebugUnitTest` - Run Android unit tests for debug build only +- `./gradlew :scan-engine:iosX64Test` - Run iOS unit tests for x64 simulator +- `./gradlew :scan-engine:iosSimulatorArm64Test` - Run iOS unit tests for ARM64 simulator +- `./gradlew :scan-engine:connectedAndroidTest` - Run Android instrumented tests on connected devices + +### Code Quality +- `./gradlew spotlessCheck` - Check code formatting (Ktlint) +- `./gradlew spotlessApply` - Apply code formatting fixes +- `./gradlew lint` - Run Android lint checks +- `./gradlew lintFix` - Apply safe lint suggestions + +## Project Architecture + +### Module Structure +- `scan-engine/` - Main KMP library module containing core scanning functionality +- `sample/` - Demo application showcasing library usage (Android + iOS) +- `iosApp/` - iOS-specific app wrapper +- `buildSrc/` - Gradle build configuration and constants + +### Platform Structure (KMP) +The library uses Kotlin Multiplatform with the following source sets: + +- `commonMain/` - Shared code across platforms including: + - Core interfaces (`CameraScanner`, `BarcodeScanner`) + - UI components using Compose Multiplatform + - Common data models and business logic + +- `androidMain/` - Android-specific implementations: + - CameraX integration for camera functionality + - ML Kit for barcode detection + - Activity contracts and Android-specific UI + +- `iosMain/` - iOS-specific implementations: + - AVFoundation camera integration + - Core ML barcode detection + - iOS-specific UI components + +- Platform-specific test directories: `androidUnitTest/`, `iosTest/`, `commonTest/` + +### Core Architecture Patterns + +**Scanning Interfaces:** +- `CameraScanner` - Interface for camera-based scanning with Compose integration +- `BarcodeScanner` - Interface for background/hardware scanner integration +- Both use reactive patterns with Kotlin Flow for result observation + +**Platform Abstraction:** +- `expect/actual` pattern for platform-specific implementations +- `Platform.kt` files in each platform source set +- Camera controllers have platform-specific implementations + +**Compose Multiplatform UI:** +- Shared UI components in `commonMain/ui/` +- Platform-specific screen implementations using `@Composable` functions +- Material 3 design system with custom theming + +## Key Configuration + +### Build Configuration +- Target Android API 24+ (minSdk: 24, compileSdk: 35) +- iOS targets: iosX64, iosArm64, iosSimulatorArm64 +- Java 17 compatibility +- Jetpack Compose and Compose Multiplatform enabled + +### Dependencies +- Core KMP dependencies managed via version catalogs (`libs.versions.toml`) +- Android: CameraX, ML Kit, Activity Compose +- iOS: Native iOS frameworks via Cinterop +- Testing: Mokkery for mocking, Robolectric for Android unit tests + +### Code Style +- Spotless with Ktlint for code formatting +- Official Kotlin code style +- Automatic formatting applied in pre-commit hooks + +## Testing Strategy + +### Test Exclusions +- UI tests are excluded from unit test runs (see `testOptions` in `scan-engine/build.gradle.kts`) +- UI tests run separately as instrumented tests + +### Platform Testing +- Android: Unit tests with Robolectric, instrumented tests with AndroidJUnit4 +- iOS: Native Kotlin/Native unit tests +- Common: Shared business logic testing + +## Development Notes +- Avoid doing any changes to the `build/` folders they are auto generated by Gradle + +### KMP Migration Status +- Project is currently undergoing KMP migration (branch: `msirok/task/UNTIL-14107/kmp-migration`) +- iOS implementation recently added with unit testing framework +- Both platforms share core business logic and UI components + +### Published Library +- Available on Maven Central repository as `io.github.tillhub:scan-engine:x.x.x` +- Current version: 2.0.1 (see `gradle/libs.versions.toml`) +- Namespace: `de.tillhub.scanengine` + +### Framework Generation +- iOS frameworks are generated for Xcode integration +- Framework name: `scan-engine` for the library, `ComposeApp` for sample \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index bdff3ce..d9df5ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.android.application).apply(false) alias(libs.plugins.kotlin.android).apply(false) alias(libs.plugins.spotless).apply(false) + alias(libs.plugins.maven.publish).apply(false) } subprojects { @@ -16,7 +17,11 @@ subprojects { target("**/*.kt") targetExclude("${layout.buildDirectory}/**/*.kt") - ktlint() + ktlint().editorConfigOverride( + mapOf( + "ktlint_function_naming_ignore_when_annotated_with" to "Composable" + ) + ) } kotlinGradle { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c800bb..7abaec4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] -kotlin = "2.1.21" -agp = "8.10.1" +scan-engine = "2.0.1" +kotlin = "2.2.0" +agp = "8.11.1" compose = "1.8.1" atomicfu = "0.28.0" @@ -16,12 +17,13 @@ kotlinCoroutines = "1.10.2" lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" -spotless = "6.21.0" -mokkery = "2.8.0" +spotless = "7.1.0" +mokkery = "2.9.0" uiTestJunit4 = "1.8.3" robolectric = "4.15" androidx-test = "1.6.1" +maven-publish = "0.34.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -58,6 +60,7 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } +maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } [bundles] core = [ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c854a28..feb3b65 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jun 12 08:47:36 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt index ab721d2..d204652 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/App.kt @@ -113,5 +113,6 @@ fun ScanSection( } enum class ScanScreen { - CameraScan, Bluetooth + CameraScan, + Bluetooth, } diff --git a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt index b8ba61c..6bd9cc4 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/scanengine/sample/theme/Type.kt @@ -7,15 +7,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp, - ), - /* Other default text styles to override +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + /* Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, @@ -30,5 +32,5 @@ val Typography = Typography( lineHeight = 16.sp, letterSpacing = 0.5.sp ) - */ -) + */ + ) diff --git a/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt b/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt index 7aca0be..d1460ab 100644 --- a/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt +++ b/sample/src/iosMain/kotlin/de/tillhub/scanengine/sample/MainViewController.kt @@ -2,4 +2,8 @@ package de.tillhub.scanengine.sample import androidx.compose.ui.window.ComposeUIViewController +/** + * Entry point for iOS app - called from Swift ContentView + */ +@Suppress("FunctionName") fun MainViewController() = ComposeUIViewController { App() } diff --git a/scan_engine/build.gradle.kts b/scan-engine/build.gradle.kts similarity index 62% rename from scan_engine/build.gradle.kts rename to scan-engine/build.gradle.kts index f8750ac..80617b4 100644 --- a/scan_engine/build.gradle.kts +++ b/scan-engine/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) alias(libs.plugins.mokkery) + alias(libs.plugins.maven.publish) } kotlin { @@ -21,6 +22,8 @@ kotlin { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) + publishLibraryVariants("release") + dependencies { androidTestImplementation(libs.androidx.ui.test.junit4.android) androidTestImplementation(libs.androidx.ui.test.manifest) @@ -30,7 +33,7 @@ kotlin { } } - val xcfName = "scan_engine" + val xcfName = "scan-engine" iosX64 { binaries.framework { baseName = xcfName } } iosArm64 { binaries.framework { baseName = xcfName } } iosSimulatorArm64 { binaries.framework { baseName = xcfName } } @@ -67,7 +70,6 @@ kotlin { @OptIn(ExperimentalComposeLibrary::class) implementation(compose.uiTest) } - } } @@ -99,7 +101,10 @@ android { unitTests { all { // We want to exclude all UI tests from the unit tests - it.exclude("**/scanengine/camera/ui/**") + it.exclude( + "**/scanengine/ui/components/**", + "**/scanengine/camera/ui/**", + ) } } } @@ -109,3 +114,59 @@ compose.resources { packageOfResClass = "de.tillhub.scanengine.resources" generateResClass = auto } + +mavenPublishing { + // Define coordinates for the published artifact + coordinates( + groupId = "io.github.tillhub", + artifactId = "scan-engine", + version = + libs.versions.scan.engine + .get(), + ) + + // Configure POM metadata for the published artifact + pom { + name.set("Scan Engine") + description.set("Kotlin MultiPlatform Library that provides unified barcode scanning functionality for Android and iOS platforms") + inceptionYear.set("2025") + url.set("https://github.com/tillhub/scan-engine") + + licenses { + license { + name.set("MIT") + url.set("https://opensource.org/licenses/MIT") + } + } + + // Specify developers information + developers { + developer { + id.set("djordjeh") + name.set("Đorđe Hrnjez") + email.set("dorde.hrnjez@unzer.com") + } + developer { + id.set("SloInfinity") + name.set("Martin Sirok") + email.set("m.sirok.ext@unzer.com") + } + developer { + id.set("shekar-allam") + name.set("Chandrashekar Allam") + email.set("chandrashekar.allam@unzer.com") + } + } + + // Specify SCM information + scm { + url.set("https://github.com/tillhub/scan-engine") + } + } + + // Configure publishing to Maven Central + publishToMavenCentral() + + // Enable GPG signing for all publications + signAllPublications() +} diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt new file mode 100644 index 0000000..c39558c --- /dev/null +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt @@ -0,0 +1,45 @@ +package de.tillhub.scanengine.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.tillhub.scanengine.ui.components.BottomButton +import de.tillhub.scanengine.ui.theme.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BottomButtonInstrumentedTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun submitButtonTest() { + var clicks = 0 + + composeTestRule.setContent { + AppTheme { + BottomButton( + isEnable = true, + modifier = Modifier.testTag("submitButton"), + text = "Submit", + onClick = { + clicks++ + }, + ) + } + } + + composeTestRule.onNodeWithTag("submitButton").performClick() + composeTestRule.onNodeWithContentDescription("Bottom button label").assertTextEquals("Submit") + + assertEquals("onClick should be called once", 1, clicks) + } +} diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt new file mode 100644 index 0000000..82071cc --- /dev/null +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt @@ -0,0 +1,70 @@ +package de.tillhub.scanengine.ui + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.tillhub.scanengine.ui.components.Toolbar +import de.tillhub.scanengine.ui.theme.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ToolbarInstrumentedTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun toolbarDisplaysTitleCorrectly() { + val testTitle = "Test Title" + + composeTestRule.setContent { + AppTheme { + Toolbar( + title = testTitle, + onClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("toolbarTitle").assertTextEquals(testTitle) + } + + @Test + fun toolbarClickHandlerTest() { + var clicks = 0 + + composeTestRule.setContent { + AppTheme { + Toolbar( + title = "Sample Title", + onClick = { + clicks++ + }, + ) + } + } + + composeTestRule.onNodeWithTag("toolbarIcon").performClick() + + assertEquals("onClick should be called once", 1, clicks) + } + + @Test + fun toolbarIconContentDescriptionTest() { + composeTestRule.setContent { + AppTheme { + Toolbar( + title = "Test Title", + onClick = {}, + ) + } + } + + composeTestRule.onNodeWithContentDescription("close").assertExists() + } +} diff --git a/scan_engine/src/androidMain/AndroidManifest.xml b/scan-engine/src/androidMain/AndroidManifest.xml similarity index 100% rename from scan_engine/src/androidMain/AndroidManifest.xml rename to scan-engine/src/androidMain/AndroidManifest.xml diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt similarity index 75% rename from scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt rename to scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt index c6dafe8..62c7daf 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt +++ b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandler.kt @@ -24,12 +24,10 @@ internal class AndroidPermissionHandler( * * @return `true` if camera permission is granted, `false` otherwise. */ - override fun hasCameraPermission(): Boolean { - return ContextCompat.checkSelfPermission( - context, - Manifest.permission.CAMERA, - ) == PackageManager.PERMISSION_GRANTED - } + override fun hasCameraPermission(): Boolean = ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED /** * Composable function to request camera permission. @@ -43,22 +41,27 @@ internal class AndroidPermissionHandler( * @param onDenied Callback to be invoked if the camera permission is denied. */ @Composable - override fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { isGranted -> - if (isGranted) { - onGranted() - } else { - onDenied() - } - }, - ) + override fun requestCameraPermission( + onGranted: () -> Unit, + onDenied: () -> Unit, + ) { + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + if (isGranted) { + onGranted() + } else { + onDenied() + } + }, + ) - val permissionStatus = ContextCompat.checkSelfPermission( - context, - Manifest.permission.CAMERA, - ) + val permissionStatus = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) when (permissionStatus) { PackageManager.PERMISSION_GRANTED -> onGranted() diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt similarity index 73% rename from scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt rename to scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt index 5d9d671..4c3b6e4 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt +++ b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraController.android.kt @@ -42,18 +42,19 @@ internal actual class CameraController( private val context: Context, private val lifecycleOwner: LifecycleOwner, barcodeScanned: (String) -> Unit, - scanner: BarcodeScanner = BarcodeScanning.getClient( - BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build(), - ), - private val analyzer: ImageAnalysis.Analyzer = QRImageAnalyzer( - scanner = scanner, - inputImageGenerator = InputImageGenerator(), - barcodeScanned = barcodeScanned, - ), + scanner: BarcodeScanner = + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build(), + ), + private val analyzer: ImageAnalysis.Analyzer = + QRImageAnalyzer( + scanner = scanner, + inputImageGenerator = InputImageGenerator(), + barcodeScanned = barcodeScanned, + ), private val executor: Executor = ContextCompat.getMainExecutor(context), - private val cameraHandler: CameraHandler = CameraHandlerImpl(context, executor) + private val cameraHandler: CameraHandler = CameraHandlerImpl(context, executor), ) { - private var cameraProvider: CameraProvider? = null private var preview: Preview? = null private var camera: Camera? = null @@ -70,33 +71,41 @@ internal actual class CameraController( * bound and is ready to display the preview. This is an optional parameter * and defaults to an empty function. */ - internal fun bindCamera(previewView: PreviewView, onCameraReady: () -> Unit = {}) { + internal fun bindCamera( + previewView: PreviewView, + onCameraReady: () -> Unit = {}, + ) { this.previewView = previewView cameraHandler.getCameraProvider { provider -> cameraProvider = provider cameraProvider?.unbindAll() - preview = Preview.Builder() - .setResolutionSelector(createResolutionSelector()) - .build() - .also { - it.surfaceProvider = previewView.surfaceProvider - } - - val imageAnalyzer = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { - it.setAnalyzer(executor, analyzer) - } - - camera = cameraProvider?.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageAnalyzer, - ) + preview = + Preview + .Builder() + .setResolutionSelector(createResolutionSelector()) + .build() + .also { + it.surfaceProvider = previewView.surfaceProvider + } + + val imageAnalyzer = + ImageAnalysis + .Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer(executor, analyzer) + } + + camera = + cameraProvider?.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer, + ) onCameraReady() } @@ -124,12 +133,11 @@ internal actual class CameraController( * * @return A configured [ResolutionSelector] instance. */ - private fun createResolutionSelector(): ResolutionSelector { - return ResolutionSelector.Builder() - .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) - .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) - .build() - } + private fun createResolutionSelector(): ResolutionSelector = ResolutionSelector + .Builder() + .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) + .build() } /** @@ -146,7 +154,6 @@ internal class QRImageAnalyzer( private val inputImageGenerator: InputImageGenerator, private val barcodeScanned: (String) -> Unit, ) : ImageAnalysis.Analyzer { - /** * Analyzes an image from the camera preview to detect barcodes. * This method is called by the camera framework for each new frame. @@ -166,14 +173,16 @@ internal class QRImageAnalyzer( if (mediaImage != null) { val image = inputImageGenerator.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - scanner.process(image).addOnSuccessListener { list -> - if (list.isNotEmpty() && list[0].rawValue != null) { - barcodeScanned(list[0].rawValue!!) - scanner.close() + scanner + .process(image) + .addOnSuccessListener { list -> + if (list.isNotEmpty() && list[0].rawValue != null) { + barcodeScanned(list[0].rawValue!!) + scanner.close() + } + }.addOnCompleteListener { + imageProxy.close() } - }.addOnCompleteListener { - imageProxy.close() - } } } } @@ -183,7 +192,8 @@ internal class QRImageAnalyzer( * This is used to prepare images from the camera for processing by ML Kit's BarcodeScanner. */ internal class InputImageGenerator { - fun fromMediaImage(mediaImage: Image, rotationDegrees: Int): InputImage { - return InputImage.fromMediaImage(mediaImage, rotationDegrees) - } + fun fromMediaImage( + mediaImage: Image, + rotationDegrees: Int, + ): InputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees) } diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt similarity index 93% rename from scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt rename to scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt index fdd803c..456a96d 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt +++ b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/CameraHandler.kt @@ -12,8 +12,8 @@ internal interface CameraHandler { internal class CameraHandlerImpl( private val context: Context, - private val executor: Executor -) : CameraHandler{ + private val executor: Executor, +) : CameraHandler { override fun getCameraProvider(callback: (cameraProvider: CameraProvider) -> Unit) { val cameraProviderFuture = ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener( @@ -21,4 +21,4 @@ internal class CameraHandlerImpl( executor, ) } -} \ No newline at end of file +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt similarity index 75% rename from scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt rename to scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt index 81b6ab7..41e87b8 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt +++ b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/common/CameraProvider.kt @@ -8,15 +8,16 @@ import androidx.lifecycle.LifecycleOwner internal interface CameraProvider { fun unbindAll() + fun bindToLifecycle( lifecycleOwner: LifecycleOwner, cameraSelector: CameraSelector, - vararg useCases: UseCase? + vararg useCases: UseCase?, ): Camera } internal class CameraProviderImpl( - private val cameraProvider: ProcessCameraProvider + private val cameraProvider: ProcessCameraProvider, ) : CameraProvider { override fun unbindAll() { cameraProvider.unbindAll() @@ -25,6 +26,6 @@ internal class CameraProviderImpl( override fun bindToLifecycle( lifecycleOwner: LifecycleOwner, cameraSelector: CameraSelector, - vararg useCases: UseCase? - ): Camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, *useCases) -} \ No newline at end of file + vararg useCases: UseCase?, + ): Camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, *useCases) +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt similarity index 78% rename from scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt rename to scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt index ea33a57..1396c08 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt +++ b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.android.kt @@ -37,22 +37,24 @@ internal actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit) var scanKey: String? = null - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - val resultData = result.data?.extras?.getString(CameraScanActivity.DATA_KEY) + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + val resultData = result.data?.extras?.getString(CameraScanActivity.DATA_KEY) - val scanResult = if (result.resultCode == Activity.RESULT_OK && !resultData.isNullOrEmpty()) { - ScannerEvent.ScanResult( - value = resultData, - scanKey = scanKey, - ) - } else { - ScannerEvent.Camera.Canceled - } + val scanResult = + if (result.resultCode == Activity.RESULT_OK && !resultData.isNullOrEmpty()) { + ScannerEvent.ScanResult( + value = resultData, + scanKey = scanKey, + ) + } else { + ScannerEvent.Camera.Canceled + } - onResult(scanResult) - } + onResult(scanResult) + } LaunchedEffect(Unit) { launchCallback.value = { diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt similarity index 82% rename from scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt rename to scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt index e8e0a2b..4ba4e24 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt +++ b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivity.kt @@ -6,15 +6,15 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent class CameraScanActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { CameraScreen( onResult = { barcode -> - val resultIntent = Intent().apply { - putExtra(DATA_KEY, barcode) - } + val resultIntent = + Intent().apply { + putExtra(DATA_KEY, barcode) + } setResult(RESULT_OK, resultIntent) finish() }, diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt similarity index 94% rename from scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt rename to scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt index 0553701..8d44191 100644 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt +++ b/scan-engine/src/androidMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.android.kt @@ -37,9 +37,10 @@ internal actual fun cameraPreview( val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val previewView = remember { PreviewView(context) } - val controller = remember { - CameraController(context, lifecycleOwner, barcodeScanned) - } + val controller = + remember { + CameraController(context, lifecycleOwner, barcodeScanned) + } DisposableEffect(previewView) { controller.bindCamera(previewView) {} diff --git a/scan_engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt similarity index 73% rename from scan_engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt rename to scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt index d57c1bd..08f70be 100644 --- a/scan_engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt +++ b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt @@ -38,35 +38,39 @@ internal class CameraControllerTest { @BeforeTest fun setup() { - cameraProvider = mock { - every { unbindAll() } returns Unit - every { bindToLifecycle(any(), any(), any(), any()) } returns mock() - } - callbacks = mock { - every { onCameraReady() } returns Unit - every { barcodeScanned(any()) } returns Unit - } + cameraProvider = + mock { + every { unbindAll() } returns Unit + every { bindToLifecycle(any(), any(), any(), any()) } returns mock() + } + callbacks = + mock { + every { onCameraReady() } returns Unit + every { barcodeScanned(any()) } returns Unit + } context = ApplicationProvider.getApplicationContext() lifecycleOwner = mock() scanner = mock() analyzer = mock() executor = mock() - cameraHandler = mock { - every { getCameraProvider(any()) } calls { (callback: (CameraProvider) -> Unit) -> - callback(cameraProvider) + cameraHandler = + mock { + every { getCameraProvider(any()) } calls { (callback: (CameraProvider) -> Unit) -> + callback(cameraProvider) + } } - } - target = CameraController( - context, - lifecycleOwner, - callbacks::barcodeScanned, - scanner, - analyzer, - executor, - cameraHandler - ) + target = + CameraController( + context, + lifecycleOwner, + callbacks::barcodeScanned, + scanner, + analyzer, + executor, + cameraHandler, + ) } @Test @@ -101,6 +105,7 @@ internal class CameraControllerTest { interface Callbacks { fun barcodeScanned(barcode: String) + fun onCameraReady() } -} \ No newline at end of file +} diff --git a/scan_engine/src/commonMain/composeResources/values-de/strings.xml b/scan-engine/src/commonMain/composeResources/values-de/strings.xml similarity index 100% rename from scan_engine/src/commonMain/composeResources/values-de/strings.xml rename to scan-engine/src/commonMain/composeResources/values-de/strings.xml diff --git a/scan_engine/src/commonMain/composeResources/values/strings.xml b/scan-engine/src/commonMain/composeResources/values/strings.xml similarity index 100% rename from scan_engine/src/commonMain/composeResources/values/strings.xml rename to scan-engine/src/commonMain/composeResources/values/strings.xml diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt similarity index 99% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt index 3138e4f..1c8aa87 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/BarcodeScanner.kt @@ -8,9 +8,14 @@ import kotlinx.coroutines.flow.Flow interface BarcodeScanner { fun observeScannerResults(): Flow + fun scanWithKey(scanKey: String? = null) + fun startPairingScreen(scanner: ScannerType) + fun observeScanners(): Flow> + suspend fun disconnect(scannerId: String): ScannerResponse + suspend fun connect(scannerId: String): ScannerResponse } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt similarity index 100% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/CameraScanner.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt similarity index 54% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt index cb9b245..3806d35 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/KeyEventScanner.kt @@ -3,5 +3,8 @@ package de.tillhub.scanengine import androidx.compose.ui.input.key.KeyEvent interface KeyEventScanner { - fun dispatchKeyEvent(event: KeyEvent, scanKey: String? = null) + fun dispatchKeyEvent( + event: KeyEvent, + scanKey: String? = null, + ) } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt similarity index 95% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt index 716bcce..093d3bd 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ScanEngine.kt @@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.drop * ``` */ class ScanEngine private constructor() { - private val mutableScannerEvents = MutableStateFlow(ScannerEvent.External.NotConnected) /** @@ -62,9 +61,7 @@ class ScanEngine private constructor() { * * @return A new [CameraScanner] instance. */ - fun newCameraScanner(): CameraScanner { - return DefaultCameraScanner(mutableScannerEvents) - } + fun newCameraScanner(): CameraScanner = DefaultCameraScanner(mutableScannerEvents) companion object : SingletonHolder(::ScanEngine) } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt similarity index 100% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/CameraController.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt similarity index 88% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt index cf8ee86..621e9b7 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/DefaultCameraScanner.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.StateFlow internal class DefaultCameraScanner( private val mutableScannerEvents: MutableStateFlow, ) : CameraScanner { - /** * Observes the results of the camera scanner. * @@ -31,8 +30,7 @@ internal class DefaultCameraScanner( * @return A [CameraScanContract] that can be used to launch the camera scanner. */ @Composable - override fun cameraScannerLauncher(): CameraScanContract = - rememberCameraScanLauncher { result -> - mutableScannerEvents.tryEmit(result) - } + override fun cameraScannerLauncher(): CameraScanContract = rememberCameraScanLauncher { result -> + mutableScannerEvents.tryEmit(result) + } } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt similarity index 88% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt index 33be74e..23b717d 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/PermissionHandler.kt @@ -23,7 +23,10 @@ internal interface PermissionHandler { * @param onDenied Callback invoked when permission is denied. */ @Composable - fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) + fun requestCameraPermission( + onGranted: () -> Unit, + onDenied: () -> Unit, + ) } @Composable diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt similarity index 91% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt index 0be0e15..225038a 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.kt @@ -24,6 +24,4 @@ interface CameraScanContract { * @return An instance of [CameraScanContract] that can be used to initiate a camera scan. */ @Composable -internal expect fun rememberCameraScanLauncher( - onResult: (ScannerEvent) -> Unit, -): CameraScanContract +internal expect fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit): CameraScanContract diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt similarity index 98% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt index a6e47c4..2ccb1dc 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -121,6 +122,7 @@ internal fun CameraScreen( text = stringResource(Res.string.permission_required_message), ) BottomButton( + modifier = Modifier.testTag("submitButton"), text = stringResource(Res.string.permission_camera_request), onClick = { askForPermission.value = true }, ) diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt similarity index 95% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt index d121ca3..8e833ed 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/common/SingletonHolder.kt @@ -19,7 +19,9 @@ import kotlin.concurrent.Volatile * The `@OptIn(InternalCoroutinesApi::class)` annotation is used because [SynchronizedObject] and its `synchronized` function are part of Kotlin's internal coroutines API. */ @OptIn(InternalCoroutinesApi::class) -open class SingletonHolder(creator: () -> T) : SynchronizedObject() { +open class SingletonHolder( + creator: () -> T, +) : SynchronizedObject() { private var creator: (() -> T)? = creator @Volatile diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt similarity index 100% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/Scanner.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt similarity index 76% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt index d514c86..d90389c 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerEvent.kt @@ -3,12 +3,19 @@ package de.tillhub.scanengine.data sealed class ScannerEvent { sealed class External : ScannerEvent() { data object NotConnected : External() + data object Connected : External() - class Connecting(val scannerId: String) : External() + + class Connecting( + val scannerId: String, + ) : External() } sealed class Camera : ScannerEvent() { - class InProgress(val scanKey: String?) : Camera() + class InProgress( + val scanKey: String?, + ) : Camera() + data object Canceled : Camera() } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt similarity index 76% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt index 348399a..1bf7b1f 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerResponse.kt @@ -3,12 +3,17 @@ package de.tillhub.scanengine.data sealed class ScannerResponse { sealed class Error : ScannerResponse() { data object NotFound : Error() + data object Disconnect : Error() - class Connect(val barcode: Int) : Error() // TODO [barcode] should be + + class Connect( + val barcode: Int, + ) : Error() // TODO [barcode] should be } sealed class Success : ScannerResponse() { data object Connect : Success() + data object Disconnect : Success() } } diff --git a/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt new file mode 100644 index 0000000..16c4fdb --- /dev/null +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt @@ -0,0 +1,9 @@ +package de.tillhub.scanengine.data + +enum class ScannerType( + val value: String, +) { + SUNMI("SUNMI"), + ZEBRA("ZEBRA"), + UNKNOWN("UNKNOWN"), +} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt similarity index 76% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt index 59a214d..d931901 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/BottomButton.kt @@ -9,7 +9,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import de.tillhub.scanengine.ui.theme.MagneticGrey import de.tillhub.scanengine.ui.theme.OrbitalBlue @@ -18,15 +19,15 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @Preview internal fun BottomButton( + modifier: Modifier = Modifier, isEnable: Boolean = true, text: String, onClick: () -> Unit = {}, ) { Button( enabled = isEnable, - modifier = Modifier - .fillMaxWidth() - .testTag("submitButton"), + modifier = modifier + .fillMaxWidth(), shape = RectangleShape, onClick = onClick, colors = ButtonDefaults.buttonColors( @@ -35,7 +36,9 @@ internal fun BottomButton( ) { Text( style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier + .padding(vertical = 8.dp) + .semantics { contentDescription = "Bottom button label" }, text = text, ) } diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt similarity index 100% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/components/Toolbar.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt similarity index 100% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Color.kt diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt similarity index 87% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt index 416485a..a059547 100644 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Style.kt @@ -28,7 +28,8 @@ internal fun textFieldTransparentColors() = TextFieldDefaults.colors( unfocusedIndicatorColor = Color.Transparent, ) -internal val TabletScaffoldModifier = Modifier - .width(380.dp) - .height(600.dp) - .clip(RoundedCornerShape(8.dp)) +internal val TabletScaffoldModifier = + Modifier + .width(380.dp) + .height(600.dp) + .clip(RoundedCornerShape(8.dp)) diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt similarity index 100% rename from scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt rename to scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Theme.kt diff --git a/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt new file mode 100644 index 0000000..573bafe --- /dev/null +++ b/scan-engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt @@ -0,0 +1,115 @@ +package de.tillhub.scanengine.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +internal val typography = + Typography( + displayLarge = + TextStyle( + fontWeight = FontWeight.Light, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = + TextStyle( + fontWeight = FontWeight.Light, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = + TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = + TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = + TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = + TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt similarity index 99% rename from scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt rename to scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt index 7421611..7acd2ee 100644 --- a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt +++ b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ScanEngineTest.kt @@ -6,7 +6,6 @@ import kotlin.test.Test import kotlin.test.assertTrue class ScanEngineTest { - lateinit var scanEngine: ScanEngine @BeforeTest diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt similarity index 100% rename from scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt rename to scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/DefaultCameraScannerTest.kt diff --git a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt similarity index 72% rename from scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt rename to scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt index a58beec..b7b8e58 100644 --- a/scan_engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt +++ b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScreenTest.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -22,40 +21,41 @@ import kotlin.test.Test @OptIn(ExperimentalTestApi::class) class CameraScreenTest { - private var hasPermission = false private var permissionGranted: () -> Unit = {} private var permissionDenied: () -> Unit = {} - private val permissionHandler = object : PermissionHandler { - override fun hasCameraPermission(): Boolean = hasPermission - - @Composable - override fun requestCameraPermission( - onGranted: () -> Unit, - onDenied: () -> Unit, - ) { - permissionGranted = onGranted - permissionDenied = onDenied - Column( - modifier = Modifier - .padding(top = 16.dp) - .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween, + private val permissionHandler = + object : PermissionHandler { + override fun hasCameraPermission(): Boolean = hasPermission + + @Composable + override fun requestCameraPermission( + onGranted: () -> Unit, + onDenied: () -> Unit, ) { - Text( - style = MaterialTheme.typography.labelLarge, - modifier = Modifier - .padding( - vertical = 16.dp, - horizontal = 16.dp, - ) - .semantics { contentDescription = "Permission request" }, - text = "Permission request", - ) + permissionGranted = onGranted + permissionDenied = onDenied + Column( + modifier = + Modifier + .padding(top = 16.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding( + vertical = 16.dp, + horizontal = 16.dp, + ).semantics { contentDescription = "Permission request" }, + text = "Permission request", + ) + } } } - } @Test fun missingPermissions_showPermissionExplanation() = runComposeUiTest { diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt similarity index 90% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt index f8aeb85..9ce40cc 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraController.ios.kt @@ -51,10 +51,8 @@ internal actual class CameraController( private val onCameraError: (String) -> Unit, barcodeScanned: (String) -> Unit, private val analyzer: QRImageAnalyzer = QRImageAnalyzer(barcodeScanned), - private val dispatcher: Dispatcher = DispatcherImpl + private val dispatcher: Dispatcher = DispatcherImpl, ) : UIViewController(null, null) { - - /** * Called after the controller's view is loaded into memory. * This method initializes the camera setup. @@ -152,12 +150,13 @@ internal actual class CameraController( private fun setupScanner() { metadataOutput.setMetadataObjectsDelegate( delegate = analyzer, - queue = dispatch_get_main_queue() + queue = dispatch_get_main_queue(), ) if (cameraWrapper.isRunning() == true) { metadataOutput.addMetadataObjectTypes( - types = listOf( + types = + listOf( AVMetadataObjectTypeQRCode!!, AVMetadataObjectTypeEAN13Code!!, AVMetadataObjectTypeEAN8Code!!, @@ -170,7 +169,7 @@ internal actual class CameraController( AVMetadataObjectTypeAztecCode!!, AVMetadataObjectTypeDataMatrixCode!!, AVMetadataObjectTypeUPCECode!!, - ) + ), ) } } @@ -191,8 +190,8 @@ internal actual class CameraController( internal class QRImageAnalyzer( private val onCodeScanned: (String) -> Unit, private val debounceMs: Long = 1000L, -) : NSObject(), AVCaptureMetadataOutputObjectsDelegateProtocol { - +) : NSObject(), + AVCaptureMetadataOutputObjectsDelegateProtocol { private val isProcessing = atomic(false) private val scope = CoroutineScope(Dispatchers.Main) private var lastScannedCode: String? = null @@ -219,15 +218,16 @@ internal class QRImageAnalyzer( ) { if (isProcessing.value) return - didOutputMetadataObjects.firstOrNull { - it is AVMetadataMachineReadableCodeObject && - !it.stringValue.isNullOrEmpty() && - it.stringValue != lastScannedCode - }?.let { scannedCode -> - (scannedCode as AVMetadataMachineReadableCodeObject).stringValue?.let { - processCode(it) + didOutputMetadataObjects + .firstOrNull { + it is AVMetadataMachineReadableCodeObject && + !it.stringValue.isNullOrEmpty() && + it.stringValue != lastScannedCode + }?.let { scannedCode -> + (scannedCode as AVMetadataMachineReadableCodeObject).stringValue?.let { + processCode(it) + } } - } } /** @@ -250,16 +250,17 @@ internal class QRImageAnalyzer( */ private fun processCode(code: String) { debounceJob?.cancel() - debounceJob = scope.launch { - if (isProcessing.compareAndSet(expect = false, update = true)) { - try { - lastScannedCode = code - onCodeScanned(code) - delay(debounceMs) - } finally { - isProcessing.value = false + debounceJob = + scope.launch { + if (isProcessing.compareAndSet(expect = false, update = true)) { + try { + lastScannedCode = code + onCodeScanned(code) + delay(debounceMs) + } finally { + isProcessing.value = false + } } } - } } } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt similarity index 95% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt index e787cc3..1d09a2f 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/CameraWrapper.kt @@ -20,18 +20,24 @@ import platform.UIKit.UIDevice import platform.UIKit.UIDeviceOrientation import platform.UIKit.UIView - @ExperimentalForeignApi internal interface CameraWrapper { var onError: ((CameraException) -> Unit)? fun setupSession() + fun startSession() + fun stopSession() + fun setupPreviewLayer(view: UIView) + fun setPreviewLayerFrame(view: UIView) + fun updateOrientation() + fun isRunning(): Boolean? + fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean? } @@ -54,7 +60,7 @@ internal interface CameraWrapper { @OptIn(ExperimentalForeignApi::class) internal class CameraWrapperImpl( private val captureFactory: CaptureProvider = CaptureProviderImpl, - private val dispatcher: Dispatcher = DispatcherImpl + private val dispatcher: Dispatcher = DispatcherImpl, ) : CameraWrapper { override var onError: ((CameraException) -> Unit)? = null @@ -63,7 +69,10 @@ internal class CameraWrapperImpl( sealed class CameraException : Exception() { class DeviceNotAvailable : CameraException() - class ConfigurationError(message: String) : CameraException() + + class ConfigurationError( + message: String, + ) : CameraException() } /** @@ -125,16 +134,17 @@ internal class CameraWrapperImpl( */ override fun setupPreviewLayer(view: UIView) { captureSession?.let { session -> - val newPreviewLayer = captureFactory.getCaptureVideoPreviewLayer( - session = session, - view = view, - orientation = currentVideoOrientation(), - ) + val newPreviewLayer = + captureFactory.getCaptureVideoPreviewLayer( + session = session, + view = view, + orientation = currentVideoOrientation(), + ) cameraPreviewLayer = newPreviewLayer } } - + /** * Sets the frame of the camera preview layer to match the bounds of a given UIView. * @@ -166,7 +176,7 @@ internal class CameraWrapperImpl( * `captureSession` is not initialized. */ override fun isRunning(): Boolean? = captureSession?.isRunning() - + /** * Attempts to add a `CaptureMetadataOutput` to the current `captureSession` if possible. * @@ -178,8 +188,7 @@ internal class CameraWrapperImpl( * `false` if it could not be added (e.g., due to incompatibility), * or `null` if the `captureSession` is not initialized. */ - override fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean? = - captureSession?.addOutputIfPossible(output) + override fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean? = captureSession?.addOutputIfPossible(output) /** * Cleans up the camera session and related resources. @@ -219,7 +228,6 @@ internal class CameraWrapperImpl( } } - /** * Sets up the camera inputs for the `AVCaptureSession`. * diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt similarity index 90% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt index e0f733d..774973a 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/IosPermissionHandler.kt @@ -41,7 +41,10 @@ internal class IosPermissionHandler : PermissionHandler { * @param onDenied A lambda function to be executed if the camera permission is denied. */ @Composable - override fun requestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { + override fun requestCameraPermission( + onGranted: () -> Unit, + onDenied: () -> Unit, + ) { AVCaptureDevice.requestAccessForMediaType( AVMediaTypeVideo, ) { granted -> @@ -55,8 +58,6 @@ internal class IosPermissionHandler : PermissionHandler { } @Composable -internal actual fun getPermissionHandler(): PermissionHandler { - return remember { - IosPermissionHandler() - } +internal actual fun getPermissionHandler(): PermissionHandler = remember { + IosPermissionHandler() } diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt similarity index 78% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt index 2d5661b..d6d8992 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureDevice.kt @@ -8,18 +8,18 @@ import platform.AVFoundation.position internal interface CaptureDevice { val position: AVCaptureDevicePosition + fun getAVCaptureDeviceInput(): AVCaptureDeviceInput? } class CaptureDeviceImpl( - private val avCaptureDevice: AVCaptureDevice + private val avCaptureDevice: AVCaptureDevice, ) : CaptureDevice { override val position = avCaptureDevice.position @ExperimentalForeignApi - override fun getAVCaptureDeviceInput(): AVCaptureDeviceInput? = - AVCaptureDeviceInput.deviceInputWithDevice( - avCaptureDevice, - null, - ) -} \ No newline at end of file + override fun getAVCaptureDeviceInput(): AVCaptureDeviceInput? = AVCaptureDeviceInput.deviceInputWithDevice( + avCaptureDevice, + null, + ) +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt similarity index 92% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt index 22b11ca..0c6ef23 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureMetadataOutput.kt @@ -6,24 +6,26 @@ import platform.darwin.NSObject internal interface CaptureMetadataOutput { val output: AVCaptureMetadataOutput + fun setMetadataObjectsDelegate( delegate: AVCaptureMetadataOutputObjectsDelegateProtocol, - queue: NSObject? + queue: NSObject?, ) + fun addMetadataObjectTypes(types: List) } internal class CaptureMetadataOutputImpl( - private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput() + private val metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput(), ) : CaptureMetadataOutput { override val output = metadataOutput override fun setMetadataObjectsDelegate( delegate: AVCaptureMetadataOutputObjectsDelegateProtocol, - queue: NSObject? + queue: NSObject?, ) = metadataOutput.setMetadataObjectsDelegate(delegate, queue) override fun addMetadataObjectTypes(types: List) { metadataOutput.metadataObjectTypes += types } -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt similarity index 81% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt index ec71ed2..6b5d211 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureProvider.kt @@ -11,17 +11,19 @@ import platform.UIKit.UIView internal interface CaptureProvider { fun getCaptureSession(): CaptureSession + @ExperimentalForeignApi fun getCaptureVideoPreviewLayer( session: CaptureSession, view: UIView, orientation: AVCaptureVideoOrientation, ): CaptureVideoPreviewLayer + fun getCaptureDevices(): List } @ExperimentalForeignApi -internal object CaptureProviderImpl: CaptureProvider { +internal object CaptureProviderImpl : CaptureProvider { override fun getCaptureSession(): CaptureSession = CaptureSessionImpl() override fun getCaptureVideoPreviewLayer( @@ -34,10 +36,12 @@ internal object CaptureProviderImpl: CaptureProvider { orientation = orientation, ) - override fun getCaptureDevices(): List = - AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypes( + override fun getCaptureDevices(): List = AVCaptureDeviceDiscoverySession + .discoverySessionWithDeviceTypes( listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), AVMediaTypeVideo, AVCaptureDevicePositionUnspecified, - ).devices.mapNotNull { it as? AVCaptureDevice }.map { CaptureDeviceImpl(it) } -} \ No newline at end of file + ).devices + .mapNotNull { it as? AVCaptureDevice } + .map { CaptureDeviceImpl(it) } +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt similarity index 98% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt index 67f3c09..7c5645f 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureSession.kt @@ -6,14 +6,23 @@ internal interface CaptureSession { var sessionPreset: String? fun beginConfiguration() + fun commitConfiguration() + fun isRunning(): Boolean + fun startRunning() + fun stopRunning() + fun addInputIfPossible(device: CaptureDevice): Boolean + fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean + fun canAddOutput(output: CaptureMetadataOutput): Boolean + fun addOutput(output: CaptureMetadataOutput) + fun getSession(): AVCaptureSession } @@ -27,10 +36,15 @@ internal class CaptureSessionImpl : CaptureSession { } override fun beginConfiguration() = session.beginConfiguration() + override fun commitConfiguration() = session.commitConfiguration() + override fun isRunning() = session.isRunning() + override fun startRunning() = session.startRunning() + override fun stopRunning() = session.stopRunning() + override fun addInputIfPossible(device: CaptureDevice): Boolean { val input = device.getAVCaptureDeviceInput() @@ -40,6 +54,7 @@ internal class CaptureSessionImpl : CaptureSession { } return false } + override fun addOutputIfPossible(output: CaptureMetadataOutput): Boolean { if (session.canAddOutput(output.output)) { session.addOutput(output.output) @@ -47,7 +62,10 @@ internal class CaptureSessionImpl : CaptureSession { } return false } + override fun canAddOutput(output: CaptureMetadataOutput) = session.canAddOutput(output.output) + override fun addOutput(output: CaptureMetadataOutput) = session.addOutput(output.output) + override fun getSession() = session -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt similarity index 72% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt index cfd99b0..17a5b40 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/CaptureVideoPreviewLayer.kt @@ -8,7 +8,9 @@ import platform.UIKit.UIView internal interface CaptureVideoPreviewLayer { var orientation: Long? + fun setFrame(view: UIView) + fun removeFromSuperlayer() } @@ -16,17 +18,18 @@ internal interface CaptureVideoPreviewLayer { internal class CaptureVideoPreviewLayerImpl( session: CaptureSession, view: UIView, - orientation: AVCaptureVideoOrientation + orientation: AVCaptureVideoOrientation, ) : CaptureVideoPreviewLayer { - private val avCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer( - session = session.getSession() - ).apply { - videoGravity = AVLayerVideoGravityResizeAspectFill - setFrame(view.bounds) - connection?.videoOrientation = orientation - - view.layer.addSublayer(this) - } + private val avCaptureVideoPreviewLayer = + AVCaptureVideoPreviewLayer( + session = session.getSession(), + ).apply { + videoGravity = AVLayerVideoGravityResizeAspectFill + setFrame(view.bounds) + connection?.videoOrientation = orientation + + view.layer.addSublayer(this) + } override var orientation: Long? get() = avCaptureVideoPreviewLayer.connection?.videoOrientation @@ -40,4 +43,4 @@ internal class CaptureVideoPreviewLayerImpl( } override fun removeFromSuperlayer() = avCaptureVideoPreviewLayer.removeFromSuperlayer() -} \ No newline at end of file +} diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt similarity index 69% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt index 5e51ade..49797d4 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/common/DispatchAsync.kt @@ -5,7 +5,10 @@ import platform.darwin.dispatch_async import platform.darwin.dispatch_get_global_queue internal interface Dispatcher { - fun dispatchAsync(priority: Int = DISPATCH_QUEUE_PRIORITY_HIGH, block: () -> Unit) + fun dispatchAsync( + priority: Int = DISPATCH_QUEUE_PRIORITY_HIGH, + block: () -> Unit, + ) } internal object DispatcherImpl : Dispatcher { @@ -14,9 +17,13 @@ internal object DispatcherImpl : Dispatcher { * @param block The block to dispatch. * @param priority The priority of the dispatch queue. */ - override fun dispatchAsync(priority: Int, block: () -> Unit) { + override fun dispatchAsync( + priority: Int, + block: () -> Unit, + ) { dispatch_async( - queue = dispatch_get_global_queue( + queue = + dispatch_get_global_queue( identifier = priority.toLong(), flags = 0u, ), diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt similarity index 70% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt index fe5236b..013f811 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContract.ios.kt @@ -15,9 +15,7 @@ import platform.UIKit.UIViewController * @param onResult a callback for the result of the camera scan. */ @Composable -internal actual fun rememberCameraScanLauncher( - onResult: (ScannerEvent) -> Unit, -): CameraScanContract = remember { +internal actual fun rememberCameraScanLauncher(onResult: (ScannerEvent) -> Unit): CameraScanContract = remember { object : CameraScanContract { private var viewController: UIViewController? = null @@ -35,23 +33,24 @@ internal actual fun rememberCameraScanLauncher( onResult.invoke(ScannerEvent.Camera.InProgress(scanKey)) - viewController = ComposeUIViewController { - CameraScreen( - onResult = { - onResult( - ScannerEvent.ScanResult( - value = it, - scanKey = scanKey, - ), - ) - dismiss() - }, - onDismiss = { - onResult(ScannerEvent.Camera.Canceled) - dismiss() - }, - ) - } + viewController = + ComposeUIViewController { + CameraScreen( + onResult = { + onResult( + ScannerEvent.ScanResult( + value = it, + scanKey = scanKey, + ), + ) + dismiss() + }, + onDismiss = { + onResult(ScannerEvent.Camera.Canceled) + dismiss() + }, + ) + } viewController?.let { vc -> rootVC.presentViewController(vc, animated = true, completion = null) diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt similarity index 82% rename from scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt rename to scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt index 623dbb5..6514d98 100644 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt +++ b/scan-engine/src/iosMain/kotlin/de/tillhub/scanengine/camera/ui/CameraScreen.ios.kt @@ -37,20 +37,22 @@ internal actual fun cameraPreview( barcodeScanned: (String) -> Unit, onCameraError: (String) -> Unit, ) { - val cameraController = remember { - CameraController( - barcodeScanned = barcodeScanned, - onCameraError = onCameraError, - ) - } + val cameraController = + remember { + CameraController( + barcodeScanned = barcodeScanned, + onCameraError = onCameraError, + ) + } DisposableEffect(Unit) { val notificationCenter = NSNotificationCenter.defaultCenter - val observer = notificationCenter.addObserverForName( - UIDeviceOrientationDidChangeNotification, - null, - null, - ) { _ -> cameraController.updateOrientation() } + val observer = + notificationCenter.addObserverForName( + UIDeviceOrientationDidChangeNotification, + null, + null, + ) { _ -> cameraController.updateOrientation() } onDispose { notificationCenter.removeObserver(observer) diff --git a/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt similarity index 68% rename from scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt rename to scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt index c54a2d6..c1bf117 100644 --- a/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt +++ b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraControllerTest.kt @@ -1,7 +1,6 @@ package de.tillhub.scanengine.camera import de.tillhub.scanengine.camera.common.CaptureMetadataOutput -import de.tillhub.scanengine.camera.common.CaptureSession import de.tillhub.scanengine.camera.common.Dispatcher import dev.mokkery.answering.returns import dev.mokkery.every @@ -30,11 +29,15 @@ internal class CameraControllerTest { lateinit var metadataOutput: CaptureMetadataOutput lateinit var analyzer: QRImageAnalyzer - val dispatcher = object : Dispatcher { - override fun dispatchAsync(priority: Int, block: () -> Unit) { - block() + val dispatcher = + object : Dispatcher { + override fun dispatchAsync( + priority: Int, + block: () -> Unit, + ) { + block() + } } - } lateinit var callbacks: Callbacks @@ -42,36 +45,40 @@ internal class CameraControllerTest { @BeforeTest fun setup() { - metadataOutput = mock { - every { setMetadataObjectsDelegate(any(), any()) } returns Unit - every { addMetadataObjectTypes(any()) } returns Unit - } - - cameraWrapper = mock { - every { setPreviewLayerFrame(any()) } returns Unit - every { addOutputIfPossible(any()) } returns true - every { setupPreviewLayer(any()) } returns Unit - every { startSession() } returns Unit - every { stopSession() } returns Unit - every { setupSession() } returns Unit - every { updateOrientation() } returns Unit - every { isRunning() } returns true - every { onError = any() } returns Unit - } - callbacks = mock { - every { onBarcodeScanned(any()) } returns Unit - every { onCameraError(any()) } returns Unit - } + metadataOutput = + mock { + every { setMetadataObjectsDelegate(any(), any()) } returns Unit + every { addMetadataObjectTypes(any()) } returns Unit + } + + cameraWrapper = + mock { + every { setPreviewLayerFrame(any()) } returns Unit + every { addOutputIfPossible(any()) } returns true + every { setupPreviewLayer(any()) } returns Unit + every { startSession() } returns Unit + every { stopSession() } returns Unit + every { setupSession() } returns Unit + every { updateOrientation() } returns Unit + every { isRunning() } returns true + every { onError = any() } returns Unit + } + callbacks = + mock { + every { onBarcodeScanned(any()) } returns Unit + every { onCameraError(any()) } returns Unit + } analyzer = QRImageAnalyzer(callbacks::onBarcodeScanned) - target = CameraController( - cameraWrapper = cameraWrapper, - metadataOutput = metadataOutput, - onCameraError = callbacks::onCameraError, - barcodeScanned = callbacks::onBarcodeScanned, - analyzer = analyzer, - dispatcher = dispatcher - ) + target = + CameraController( + cameraWrapper = cameraWrapper, + metadataOutput = metadataOutput, + onCameraError = callbacks::onCameraError, + barcodeScanned = callbacks::onBarcodeScanned, + analyzer = analyzer, + dispatcher = dispatcher, + ) } @Test @@ -95,11 +102,12 @@ internal class CameraControllerTest { cameraWrapper.setPreviewLayerFrame(any()) metadataOutput.setMetadataObjectsDelegate( delegate = any(), - queue = any() + queue = any(), ) cameraWrapper.isRunning() metadataOutput.addMetadataObjectTypes( - types = listOf( + types = + listOf( AVMetadataObjectTypeQRCode!!, AVMetadataObjectTypeEAN13Code!!, AVMetadataObjectTypeEAN8Code!!, @@ -112,7 +120,7 @@ internal class CameraControllerTest { AVMetadataObjectTypeAztecCode!!, AVMetadataObjectTypeDataMatrixCode!!, AVMetadataObjectTypeUPCECode!!, - ) + ), ) } } @@ -146,6 +154,7 @@ internal class CameraControllerTest { interface Callbacks { fun onBarcodeScanned(barcode: String) + fun onCameraError(error: String) } -} \ No newline at end of file +} diff --git a/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt similarity index 67% rename from scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt rename to scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt index 6afe9b9..940ddac 100644 --- a/scan_engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt +++ b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/CameraWrapperTest.kt @@ -30,49 +30,58 @@ internal class CameraWrapperTest { private lateinit var captureProvider: CaptureProvider - val dispatcher = object : Dispatcher { - override fun dispatchAsync(priority: Int, block: () -> Unit) { - block() + val dispatcher = + object : Dispatcher { + override fun dispatchAsync( + priority: Int, + block: () -> Unit, + ) { + block() + } } - } private lateinit var target: CameraWrapper @BeforeTest fun setup() { - captureSession = mock { - every { sessionPreset = any() } returns Unit - every { beginConfiguration() } returns Unit - every { commitConfiguration() } returns Unit - every { isRunning() } returns true - every { startRunning() } returns Unit - every { stopRunning() } returns Unit - every { addInputIfPossible(any()) } returns true - every { addOutputIfPossible(any()) } returns true - every { canAddOutput(any()) } returns true - every { addOutput(any()) } returns Unit - every { getSession() } returns AVCaptureSession() - } - captureVideoPreviewLayer = mock { - every { orientation } returns AVCaptureVideoOrientationPortrait - every { orientation = any() } returns Unit - every { setFrame(any()) } returns Unit - every { removeFromSuperlayer() } returns Unit - } - captureDevice = mock { - every { position } returns AVCaptureDevicePositionBack - } - - captureProvider = mock { - every { getCaptureSession() } returns captureSession - every { getCaptureVideoPreviewLayer(any(), any(), any()) } returns captureVideoPreviewLayer - every { getCaptureDevices() } returns listOf(captureDevice) - } - - target = CameraWrapperImpl( - captureFactory = captureProvider, - dispatcher = dispatcher - ) + captureSession = + mock { + every { sessionPreset = any() } returns Unit + every { beginConfiguration() } returns Unit + every { commitConfiguration() } returns Unit + every { isRunning() } returns true + every { startRunning() } returns Unit + every { stopRunning() } returns Unit + every { addInputIfPossible(any()) } returns true + every { addOutputIfPossible(any()) } returns true + every { canAddOutput(any()) } returns true + every { addOutput(any()) } returns Unit + every { getSession() } returns AVCaptureSession() + } + captureVideoPreviewLayer = + mock { + every { orientation } returns AVCaptureVideoOrientationPortrait + every { orientation = any() } returns Unit + every { setFrame(any()) } returns Unit + every { removeFromSuperlayer() } returns Unit + } + captureDevice = + mock { + every { position } returns AVCaptureDevicePositionBack + } + + captureProvider = + mock { + every { getCaptureSession() } returns captureSession + every { getCaptureVideoPreviewLayer(any(), any(), any()) } returns captureVideoPreviewLayer + every { getCaptureDevices() } returns listOf(captureDevice) + } + + target = + CameraWrapperImpl( + captureFactory = captureProvider, + dispatcher = dispatcher, + ) } @Test @@ -181,4 +190,4 @@ internal class CameraWrapperTest { captureSession.addOutputIfPossible(output) } } -} \ No newline at end of file +} diff --git a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt b/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt deleted file mode 100644 index a6e1fe6..0000000 --- a/scan_engine/src/androidMain/kotlin/de/tillhub/scanengine/Platform.android.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.tillhub.scanengine - -class AndroidPlatform : Platform { - override val name: String = "Android ${android.os.Build.VERSION.SDK_INT}" - override val manufacturer: String = android.os.Build.MANUFACTURER -} - -actual fun getPlatform(): Platform = AndroidPlatform() diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt deleted file mode 100644 index f8b10c0..0000000 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/Platform.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.tillhub.scanengine - -interface Platform { - val name: String - val manufacturer: String -} - -expect fun getPlatform(): Platform diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt deleted file mode 100644 index 7e03164..0000000 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/data/ScannerType.kt +++ /dev/null @@ -1,17 +0,0 @@ -package de.tillhub.scanengine.data - -import de.tillhub.scanengine.getPlatform - -enum class ScannerType(val value: String) { - SUNMI("SUNMI"), - ZEBRA("ZEBRA"), - UNKNOWN("UNKNOWN"), - ; - - companion object { - fun get(): ScannerType = - entries.firstOrNull { - it.value == getPlatform().manufacturer - } ?: UNKNOWN - } -} diff --git a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt b/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt deleted file mode 100644 index 2557f21..0000000 --- a/scan_engine/src/commonMain/kotlin/de/tillhub/scanengine/ui/theme/Type.kt +++ /dev/null @@ -1,99 +0,0 @@ -package de.tillhub.scanengine.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -internal val typography = Typography( - displayLarge = TextStyle( - fontWeight = FontWeight.Light, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = (-0.25).sp, - ), - displayMedium = TextStyle( - fontWeight = FontWeight.Light, - fontSize = 45.sp, - lineHeight = 52.sp, - letterSpacing = 0.sp, - ), - displaySmall = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp, - ), - headlineLarge = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = 0.sp, - ), - headlineMedium = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 28.sp, - lineHeight = 36.sp, - letterSpacing = 0.sp, - ), - headlineSmall = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp, - ), - titleLarge = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp, - ), - titleMedium = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.1.sp, - ), - titleSmall = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - bodyLarge = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp, - ), - bodyMedium = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp, - ), - bodySmall = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp, - ), - labelLarge = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - labelMedium = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp, - ), - labelSmall = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp, - ), -) diff --git a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt b/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt deleted file mode 100644 index 4120775..0000000 --- a/scan_engine/src/iosMain/kotlin/de/tillhub/scanengine/Platform.ios.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.tillhub.scanengine - -import platform.UIKit.UIDevice - -class IOSPlatform : Platform { - override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion - override val manufacturer: String = "apple" -} - -actual fun getPlatform(): Platform = IOSPlatform() diff --git a/settings.gradle.kts b/settings.gradle.kts index 187af73..f97bfed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,5 +17,5 @@ dependencyResolutionManagement { } rootProject.name = "Tillhub_Scan_Engine" -include(":scan_engine") +include(":scan-engine") include(":sample") From d2355e03a331ce92ff0bac2c836cc42772311683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90or=C4=91e=20Hrnjez?= Date: Thu, 11 Sep 2025 10:35:57 +0200 Subject: [PATCH 17/19] Commons tests migration --- .../scanengine/ui/ToolbarInstrumentedTest.kt | 70 ------------------- .../ui/components/BottomButtonTest.kt} | 28 ++++---- .../scanengine/ui/components/ToolbarTest.kt | 40 +++++++++++ 3 files changed, 52 insertions(+), 86 deletions(-) delete mode 100644 scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt rename scan-engine/src/{androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt => commonTest/kotlin/de/tillhub/scanengine/ui/components/BottomButtonTest.kt} (52%) create mode 100644 scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ui/components/ToolbarTest.kt diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt deleted file mode 100644 index 82071cc..0000000 --- a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/ToolbarInstrumentedTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package de.tillhub.scanengine.ui - -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import de.tillhub.scanengine.ui.components.Toolbar -import de.tillhub.scanengine.ui.theme.AppTheme -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ToolbarInstrumentedTest { - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun toolbarDisplaysTitleCorrectly() { - val testTitle = "Test Title" - - composeTestRule.setContent { - AppTheme { - Toolbar( - title = testTitle, - onClick = {}, - ) - } - } - - composeTestRule.onNodeWithTag("toolbarTitle").assertTextEquals(testTitle) - } - - @Test - fun toolbarClickHandlerTest() { - var clicks = 0 - - composeTestRule.setContent { - AppTheme { - Toolbar( - title = "Sample Title", - onClick = { - clicks++ - }, - ) - } - } - - composeTestRule.onNodeWithTag("toolbarIcon").performClick() - - assertEquals("onClick should be called once", 1, clicks) - } - - @Test - fun toolbarIconContentDescriptionTest() { - composeTestRule.setContent { - AppTheme { - Toolbar( - title = "Test Title", - onClick = {}, - ) - } - } - - composeTestRule.onNodeWithContentDescription("close").assertExists() - } -} diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ui/components/BottomButtonTest.kt similarity index 52% rename from scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt rename to scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ui/components/BottomButtonTest.kt index c39558c..1ae7792 100644 --- a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/ui/BottomButtonInstrumentedTest.kt +++ b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ui/components/BottomButtonTest.kt @@ -1,30 +1,26 @@ -package de.tillhub.scanengine.ui +@file:OptIn(ExperimentalTestApi::class) + +package de.tillhub.scanengine.ui.components import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import de.tillhub.scanengine.ui.components.BottomButton +import androidx.compose.ui.test.runComposeUiTest import de.tillhub.scanengine.ui.theme.AppTheme -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import kotlin.test.DefaultAsserter.assertEquals +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) -class BottomButtonInstrumentedTest { - @get:Rule - val composeTestRule = createComposeRule() +class BottomButtonTest { @Test - fun submitButtonTest() { + fun submitButtonTest() = runComposeUiTest { var clicks = 0 - composeTestRule.setContent { + setContent { AppTheme { BottomButton( isEnable = true, @@ -37,8 +33,8 @@ class BottomButtonInstrumentedTest { } } - composeTestRule.onNodeWithTag("submitButton").performClick() - composeTestRule.onNodeWithContentDescription("Bottom button label").assertTextEquals("Submit") + onNodeWithTag("submitButton").performClick() + onNodeWithContentDescription("Bottom button label").assertTextEquals("Submit") assertEquals("onClick should be called once", 1, clicks) } diff --git a/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ui/components/ToolbarTest.kt b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ui/components/ToolbarTest.kt new file mode 100644 index 0000000..7344dc2 --- /dev/null +++ b/scan-engine/src/commonTest/kotlin/de/tillhub/scanengine/ui/components/ToolbarTest.kt @@ -0,0 +1,40 @@ +@file:OptIn(ExperimentalTestApi::class) + +package de.tillhub.scanengine.ui.components + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import de.tillhub.scanengine.ui.theme.AppTheme +import kotlin.test.DefaultAsserter.assertEquals +import kotlin.test.Test + +class ToolbarTest { + + @Test + fun toolbarDisplaysTitleCorrectly() = runComposeUiTest { + var clicks = 0 + val testTitle = "Test Title" + + setContent { + AppTheme { + Toolbar( + title = testTitle, + onClick = { + clicks++ + }, + ) + } + } + + onNodeWithContentDescription("close").assertExists() + onNodeWithTag("toolbarTitle").assertTextEquals(testTitle) + + onNodeWithTag("toolbarIcon").performClick() + + assertEquals("onClick should be called once", 1, clicks) + } +} From a19bc9e11e70fe1d1b5f1e8110fae5142746c826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90or=C4=91e=20Hrnjez?= Date: Thu, 11 Sep 2025 11:19:53 +0200 Subject: [PATCH 18/19] Android part test coverage --- .claude/settings.local.json | 3 +- CLAUDE.md | 5 + .../camera/contract/CameraScanContractTest.kt | 185 ++++++++++++++++++ .../scanengine/camera/ui/CameraPreviewTest.kt | 158 +++++++++++++++ .../camera/ui/CameraScanActivityTest.kt | 59 ++++++ .../camera/AndroidPermissionHandlerTest.kt | 50 +++++ .../scanengine/camera/CameraHandlerTest.kt | 30 +++ .../camera/InputImageGeneratorTest.kt | 23 +++ 8 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt create mode 100644 scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt create mode 100644 scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt create mode 100644 scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt create mode 100644 scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt create mode 100644 scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6e734f9..4494198 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(./gradlew:*)", "Bash(gh auth:*)", - "Bash(gh pr diff:*)" + "Bash(gh pr diff:*)", + "Bash(find:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index 97daf55..d4319bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,12 @@ This is a Kotlin Multiplatform (KMP) library that provides unified barcode scann - `./gradlew clean` - Clean all build artifacts ### Testing +- For the naming of test class use suffix `Test` of the original class +- If the same class name exists in common module and in platform specific like android for example, you can add platform name as suffix as well but before the word `Test` - Test coverage is ony important for the `scan-engine/` module and run tests only for this module +- Classes with functions annotated as @Composable they need to be tested via android (instrumented) tests and not unit tests +- Activities or Fragment classes cover with android (instrumented) tests, not unit tests +- Make sure at the end after you run all tests you cleanup test class for any unused variables, imports or components - `./gradlew :scan-engine:allTests` - Run tests for all platforms (Android, iOS) and create aggregated report - `./gradlew :scan-engine:test` - Run Android unit tests for all variants - `./gradlew :scan-engine:testDebugUnitTest` - Run Android unit tests for debug build only diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt new file mode 100644 index 0000000..7f03225 --- /dev/null +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt @@ -0,0 +1,185 @@ +package de.tillhub.scanengine.camera.contract + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.tillhub.scanengine.data.ScannerEvent +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class CameraScanContractTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testRememberCameraScanLauncherCreatesContract() { + val onResult: (ScannerEvent) -> Unit = { } + + var contract: CameraScanContract? = null + + composeTestRule.setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + // Verify the contract is created + assertNotNull(contract) + } + + @Test + fun testCameraScanContractLaunchWithScanKey() { + val events = mutableListOf() + val onResult: (ScannerEvent) -> Unit = { event -> + events.add(event) + } + + var contract: CameraScanContract? = null + val testScanKey = "test_scan_key_123" + + composeTestRule.setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + // Launch camera scanner with scan key + assertNotNull(contract) + contract.launchCameraScanner(testScanKey) + + // Wait for composition to settle + composeTestRule.waitForIdle() + + // Verify that InProgress event was triggered + assertTrue(events.isNotEmpty()) + val inProgressEvent = events.find { it is ScannerEvent.Camera.InProgress } + assertNotNull(inProgressEvent) + assertTrue(inProgressEvent is ScannerEvent.Camera.InProgress) + assertEquals(testScanKey, inProgressEvent.scanKey) + } + + @Test + fun testCameraScanContractLaunchWithoutScanKey() { + val events = mutableListOf() + val onResult: (ScannerEvent) -> Unit = { event -> + events.add(event) + } + + var contract: CameraScanContract? = null + + composeTestRule.setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + // Launch camera scanner without scan key (null) + assertNotNull(contract) + contract.launchCameraScanner(null) + + // Wait for composition to settle + composeTestRule.waitForIdle() + + // Verify that InProgress event was triggered with null scan key + assertTrue(events.isNotEmpty()) + val inProgressEvent = events.find { it is ScannerEvent.Camera.InProgress } + assertNotNull(inProgressEvent) + assertTrue(inProgressEvent is ScannerEvent.Camera.InProgress) + assertEquals(null, inProgressEvent.scanKey) + } + + @Test + fun testCameraScanContractWithStateChanges() { + var latestEvent: ScannerEvent? = null + var eventCount = 0 + + val onResult: (ScannerEvent) -> Unit = { event -> + latestEvent = event + eventCount++ + } + + var contract: CameraScanContract? = null + + composeTestRule.setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + // Wait for initial composition + composeTestRule.waitForIdle() + + // Verify contract is created but no events fired yet + assertNotNull(contract) + assertEquals(0, eventCount) + + // Manually trigger launch to test functionality + contract.launchCameraScanner("manual_test") + + // Wait for composition updates + composeTestRule.waitForIdle() + + // Verify event was captured + assertNotNull(latestEvent) + assertTrue(latestEvent is ScannerEvent.Camera.InProgress) + assertEquals(1, eventCount) + } + + @Test + fun testCameraScanContractRecomposition() { + var recompositionCount = 0 + + val onResult: (ScannerEvent) -> Unit = { } + + composeTestRule.setContent { + recompositionCount++ + + val contract = rememberCameraScanLauncher(onResult = onResult) + + // Contract should be remembered across recompositions + assertNotNull(contract) + } + + // Wait for composition to settle + composeTestRule.waitForIdle() + + // Verify initial composition happened + assertTrue(recompositionCount >= 1) + + // Force another composition cycle + composeTestRule.waitForIdle() + + // Contract should remain stable across recompositions + // (This tests the remember behavior) + assertTrue(recompositionCount >= 1) + } + + @Test + fun testMultipleLaunchCalls() { + val events = mutableListOf() + val onResult: (ScannerEvent) -> Unit = { event -> + events.add(event) + } + + var contract: CameraScanContract? = null + + composeTestRule.setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + // Launch multiple times with different scan keys + assertNotNull(contract) + contract.launchCameraScanner("first_launch") + contract.launchCameraScanner("second_launch") + contract.launchCameraScanner(null) + + // Wait for all compositions to complete + composeTestRule.waitForIdle() + + // Verify all launches triggered InProgress events + val inProgressEvents = events.filterIsInstance() + assertEquals(3, inProgressEvents.size) + + // Verify the scan keys are correct + assertEquals("first_launch", inProgressEvents[0].scanKey) + assertEquals("second_launch", inProgressEvents[1].scanKey) + assertEquals(null, inProgressEvents[2].scanKey) + } +} \ No newline at end of file diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt new file mode 100644 index 0000000..45ab358 --- /dev/null +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt @@ -0,0 +1,158 @@ +package de.tillhub.scanengine.camera.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class CameraPreviewTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testCameraPreviewComposition() { + var barcodeScannedCalled = false + var scannedValue = "" + var cameraErrorCalled = false + var errorMessage = "" + + val barcodeCallback: (String) -> Unit = { barcode -> + barcodeScannedCalled = true + scannedValue = barcode + } + + val errorCallback: (String) -> Unit = { error -> + cameraErrorCalled = true + errorMessage = error + } + + composeTestRule.setContent { + cameraPreview( + modifier = Modifier + .fillMaxSize() + .testTag("camera_preview"), + barcodeScanned = barcodeCallback, + onCameraError = errorCallback + ) + } + + // Verify the composable is rendered without crashing + composeTestRule.onNodeWithTag("camera_preview").assertExists() + + // Initially no callbacks should be called + assertFalse(barcodeScannedCalled) + assertFalse(cameraErrorCalled) + } + + @Test + fun testCameraPreviewCallbacksAreNotNull() { + var callbacksAreSet = false + + composeTestRule.setContent { + val barcodeCallback: (String) -> Unit = { _ -> + callbacksAreSet = true + } + + val errorCallback: (String) -> Unit = { _ -> + callbacksAreSet = true + } + + cameraPreview( + modifier = Modifier.testTag("camera_preview_callbacks"), + barcodeScanned = barcodeCallback, + onCameraError = errorCallback + ) + } + + // Verify the composable exists + composeTestRule.onNodeWithTag("camera_preview_callbacks").assertExists() + + // Callbacks should be properly set (tested by composition not crashing) + assertNotNull(composeTestRule) + } + + @Test + fun testCameraPreviewWithDifferentModifiers() { + composeTestRule.setContent { + cameraPreview( + modifier = Modifier + .fillMaxSize() + .testTag("camera_preview_modified"), + barcodeScanned = { }, + onCameraError = { } + ) + } + + // Verify composable renders with different modifiers + composeTestRule.onNodeWithTag("camera_preview_modified").assertExists() + } + + @Test + fun testCameraPreviewStateManagement() { + var currentBarcode = "" + var currentError = "" + + composeTestRule.setContent { + val barcodeState = remember { mutableStateOf("") } + val errorState = remember { mutableStateOf("") } + + currentBarcode = barcodeState.value + currentError = errorState.value + + cameraPreview( + modifier = Modifier.testTag("camera_preview_state"), + barcodeScanned = { barcode -> + barcodeState.value = barcode + }, + onCameraError = { error -> + errorState.value = error + } + ) + } + + // Verify composable is displayed + composeTestRule.onNodeWithTag("camera_preview_state").assertExists() + + // Initial state should be empty + assertEquals("", currentBarcode) + assertEquals("", currentError) + } + + @Test + fun testCameraPreviewRecomposition() { + var recompositionCount = 0 + + composeTestRule.setContent { + recompositionCount++ + + cameraPreview( + modifier = Modifier.testTag("camera_preview_recomposition"), + barcodeScanned = { }, + onCameraError = { } + ) + } + + // Verify initial composition + composeTestRule.onNodeWithTag("camera_preview_recomposition").assertExists() + assertTrue(recompositionCount >= 1) + + // Force recomposition by waiting for idle + composeTestRule.waitForIdle() + + // Verify composable still exists after potential recomposition + composeTestRule.onNodeWithTag("camera_preview_recomposition").assertExists() + } +} \ No newline at end of file diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt new file mode 100644 index 0000000..74d34bf --- /dev/null +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt @@ -0,0 +1,59 @@ +package de.tillhub.scanengine.camera.ui + +import android.app.Activity +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class CameraScanActivityTest { + + @Test + fun testDataKeyConstant() { + // Test the constant without launching activity + assertEquals("scanned_data", CameraScanActivity.DATA_KEY) + } + + @Test + fun testActivityLaunch() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val intent = Intent(context, CameraScanActivity::class.java) + + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + assertNotNull(activity) + + // Test basic activity functionality + val testBarcode = "test_barcode_123" + val resultIntent = Intent().apply { + putExtra(CameraScanActivity.DATA_KEY, testBarcode) + } + + // Verify we can set result and finish activity + activity.setResult(Activity.RESULT_OK, resultIntent) + activity.finish() + } + } + } + + @Test + fun testActivityCancellation() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val intent = Intent(context, CameraScanActivity::class.java) + + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + assertNotNull(activity) + + // Test cancellation scenario + activity.setResult(Activity.RESULT_CANCELED) + activity.finish() + } + } + } +} \ No newline at end of file diff --git a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt new file mode 100644 index 0000000..53f3db9 --- /dev/null +++ b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt @@ -0,0 +1,50 @@ +package de.tillhub.scanengine.camera + +import android.Manifest +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +internal class AndroidPermissionHandlerTest { + private lateinit var context: Context + private lateinit var target: AndroidPermissionHandler + + @BeforeTest + fun setup() { + context = ApplicationProvider.getApplicationContext() + target = AndroidPermissionHandler(context) + } + + @Test + fun testInstantiation() { + assertNotNull(target) + } + + @Test + fun testHasCameraPermissionWithGrantedPermission() { + val shadowApplication = Shadows.shadowOf(context.applicationContext as android.app.Application) + shadowApplication.grantPermissions(Manifest.permission.CAMERA) + + val result = target.hasCameraPermission() + + assertTrue(result) + } + + @Test + fun testHasCameraPermissionWithDeniedPermission() { + val shadowApplication = Shadows.shadowOf(context.applicationContext as android.app.Application) + shadowApplication.denyPermissions(Manifest.permission.CAMERA) + + val result = target.hasCameraPermission() + + assertFalse(result) + } +} \ No newline at end of file diff --git a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt new file mode 100644 index 0000000..17c43bd --- /dev/null +++ b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt @@ -0,0 +1,30 @@ +package de.tillhub.scanengine.camera + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.Executor +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull + +@RunWith(RobolectricTestRunner::class) +internal class CameraHandlerTest { + private lateinit var context: Context + private lateinit var target: CameraHandlerImpl + + @BeforeTest + fun setup() { + context = ApplicationProvider.getApplicationContext() + val executor = Executor { runnable -> runnable.run() } + target = CameraHandlerImpl(context, executor) + } + + @Test + fun testGetCameraProviderInstantiation() { + // Simple test to ensure the CameraHandlerImpl can be created + // without calling getCameraProvider which requires CameraX setup + assertNotNull(target) + } +} \ No newline at end of file diff --git a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt new file mode 100644 index 0000000..1664f89 --- /dev/null +++ b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt @@ -0,0 +1,23 @@ +package de.tillhub.scanengine.camera + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull + +@RunWith(RobolectricTestRunner::class) +internal class InputImageGeneratorTest { + private lateinit var target: InputImageGenerator + + @BeforeTest + fun setup() { + target = InputImageGenerator() + } + + @Test + fun testInputImageGeneratorInstantiates() { + // Simple test to ensure the InputImageGenerator can be created + assertNotNull(target) + } +} \ No newline at end of file From f128baa19926a2c3e454da5bbc3c115586c20ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90or=C4=91e=20Hrnjez?= Date: Thu, 11 Sep 2025 12:06:29 +0200 Subject: [PATCH 19/19] Spotless fix --- CLAUDE.md | 2 +- .../camera/contract/CameraScanContractTest.kt | 14 +- .../scanengine/camera/ui/CameraPreviewTest.kt | 12 +- .../camera/ui/CameraScanActivityTest.kt | 12 +- .../camera/AndroidPermissionHandlerTest.kt | 2 +- .../scanengine/camera/CameraHandlerTest.kt | 2 +- .../camera/InputImageGeneratorTest.kt | 2 +- .../camera/IosPermissionHandlerTest.kt | 101 ++++++++++ .../contract/CameraScanContractIosTest.kt | 182 ++++++++++++++++++ 9 files changed, 306 insertions(+), 23 deletions(-) create mode 100644 scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/IosPermissionHandlerTest.kt create mode 100644 scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractIosTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index d4319bc..b00a57d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ This is a Kotlin Multiplatform (KMP) library that provides unified barcode scann - For the naming of test class use suffix `Test` of the original class - If the same class name exists in common module and in platform specific like android for example, you can add platform name as suffix as well but before the word `Test` - Test coverage is ony important for the `scan-engine/` module and run tests only for this module -- Classes with functions annotated as @Composable they need to be tested via android (instrumented) tests and not unit tests +- Classes with functions annotated as @Composable they need to be tested via android instrumented tests and not unit tests - Activities or Fragment classes cover with android (instrumented) tests, not unit tests - Make sure at the end after you run all tests you cleanup test class for any unused variables, imports or components - `./gradlew :scan-engine:allTests` - Run tests for all platforms (Android, iOS) and create aggregated report diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt index 7f03225..095596e 100644 --- a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractTest.kt @@ -91,7 +91,7 @@ class CameraScanContractTest { fun testCameraScanContractWithStateChanges() { var latestEvent: ScannerEvent? = null var eventCount = 0 - + val onResult: (ScannerEvent) -> Unit = { event -> latestEvent = event eventCount++ @@ -112,7 +112,7 @@ class CameraScanContractTest { // Manually trigger launch to test functionality contract.launchCameraScanner("manual_test") - + // Wait for composition updates composeTestRule.waitForIdle() @@ -125,14 +125,14 @@ class CameraScanContractTest { @Test fun testCameraScanContractRecomposition() { var recompositionCount = 0 - + val onResult: (ScannerEvent) -> Unit = { } composeTestRule.setContent { recompositionCount++ - + val contract = rememberCameraScanLauncher(onResult = onResult) - + // Contract should be remembered across recompositions assertNotNull(contract) } @@ -176,10 +176,10 @@ class CameraScanContractTest { // Verify all launches triggered InProgress events val inProgressEvents = events.filterIsInstance() assertEquals(3, inProgressEvents.size) - + // Verify the scan keys are correct assertEquals("first_launch", inProgressEvents[0].scanKey) assertEquals("second_launch", inProgressEvents[1].scanKey) assertEquals(null, inProgressEvents[2].scanKey) } -} \ No newline at end of file +} diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt index 45ab358..d5d79ec 100644 --- a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraPreviewTest.kt @@ -45,7 +45,7 @@ class CameraPreviewTest { .fillMaxSize() .testTag("camera_preview"), barcodeScanned = barcodeCallback, - onCameraError = errorCallback + onCameraError = errorCallback, ) } @@ -73,7 +73,7 @@ class CameraPreviewTest { cameraPreview( modifier = Modifier.testTag("camera_preview_callbacks"), barcodeScanned = barcodeCallback, - onCameraError = errorCallback + onCameraError = errorCallback, ) } @@ -92,7 +92,7 @@ class CameraPreviewTest { .fillMaxSize() .testTag("camera_preview_modified"), barcodeScanned = { }, - onCameraError = { } + onCameraError = { }, ) } @@ -119,7 +119,7 @@ class CameraPreviewTest { }, onCameraError = { error -> errorState.value = error - } + }, ) } @@ -141,7 +141,7 @@ class CameraPreviewTest { cameraPreview( modifier = Modifier.testTag("camera_preview_recomposition"), barcodeScanned = { }, - onCameraError = { } + onCameraError = { }, ) } @@ -155,4 +155,4 @@ class CameraPreviewTest { // Verify composable still exists after potential recomposition composeTestRule.onNodeWithTag("camera_preview_recomposition").assertExists() } -} \ No newline at end of file +} diff --git a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt index 74d34bf..af06f23 100644 --- a/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt +++ b/scan-engine/src/androidInstrumentedTest/kotlin/de/tillhub/scanengine/camera/ui/CameraScanActivityTest.kt @@ -23,17 +23,17 @@ class CameraScanActivityTest { fun testActivityLaunch() { val context = InstrumentationRegistry.getInstrumentation().targetContext val intent = Intent(context, CameraScanActivity::class.java) - + ActivityScenario.launch(intent).use { scenario -> scenario.onActivity { activity -> assertNotNull(activity) - + // Test basic activity functionality val testBarcode = "test_barcode_123" val resultIntent = Intent().apply { putExtra(CameraScanActivity.DATA_KEY, testBarcode) } - + // Verify we can set result and finish activity activity.setResult(Activity.RESULT_OK, resultIntent) activity.finish() @@ -45,15 +45,15 @@ class CameraScanActivityTest { fun testActivityCancellation() { val context = InstrumentationRegistry.getInstrumentation().targetContext val intent = Intent(context, CameraScanActivity::class.java) - + ActivityScenario.launch(intent).use { scenario -> scenario.onActivity { activity -> assertNotNull(activity) - + // Test cancellation scenario activity.setResult(Activity.RESULT_CANCELED) activity.finish() } } } -} \ No newline at end of file +} diff --git a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt index 53f3db9..49c2d07 100644 --- a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt +++ b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/AndroidPermissionHandlerTest.kt @@ -47,4 +47,4 @@ internal class AndroidPermissionHandlerTest { assertFalse(result) } -} \ No newline at end of file +} diff --git a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt index 17c43bd..db116a7 100644 --- a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt +++ b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/CameraHandlerTest.kt @@ -27,4 +27,4 @@ internal class CameraHandlerTest { // without calling getCameraProvider which requires CameraX setup assertNotNull(target) } -} \ No newline at end of file +} diff --git a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt index 1664f89..a0c2bfd 100644 --- a/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt +++ b/scan-engine/src/androidUnitTest/kotlin/de/tillhub/scanengine/camera/InputImageGeneratorTest.kt @@ -20,4 +20,4 @@ internal class InputImageGeneratorTest { // Simple test to ensure the InputImageGenerator can be created assertNotNull(target) } -} \ No newline at end of file +} diff --git a/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/IosPermissionHandlerTest.kt b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/IosPermissionHandlerTest.kt new file mode 100644 index 0000000..0e139e5 --- /dev/null +++ b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/IosPermissionHandlerTest.kt @@ -0,0 +1,101 @@ +package de.tillhub.scanengine.camera + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import kotlinx.cinterop.ExperimentalForeignApi +import kotlin.test.Test +import kotlin.test.assertNotNull + +@OptIn(ExperimentalTestApi::class, ExperimentalForeignApi::class) +internal class IosPermissionHandlerTest { + + @Test + fun testIosPermissionHandlerInstantiation() { + val handler = IosPermissionHandler() + assertNotNull(handler) + } + + @Test + fun testHasCameraPermissionMethod() { + val handler = IosPermissionHandler() + + // This will check the actual iOS permission status + // The result depends on the system state, but it should not crash + val result = handler.hasCameraPermission() + + // Just verify the method executes without error + // The actual permission state is environment dependent + } + + @Test + fun testRequestCameraPermissionComposable() = runComposeUiTest { + var grantedCalled = false + var deniedCalled = false + + val onGranted = { grantedCalled = true } + val onDenied = { deniedCalled = true } + + val handler = IosPermissionHandler() + + setContent { + handler.requestCameraPermission( + onGranted = onGranted, + onDenied = onDenied, + ) + } + + // Wait for composition + waitForIdle() + + // The actual permission request behavior depends on the system state + // This test verifies that the composable can be called without crashing + } + + @Test + fun testGetPermissionHandlerComposable() = runComposeUiTest { + var handler: PermissionHandler? = null + + setContent { + handler = getPermissionHandler() + } + + waitForIdle() + + assertNotNull(handler) + // Verify it returns an IosPermissionHandler instance + // We can't use instanceof in Kotlin/Native, so we verify it's not null + // and that it has the expected interface + val result = handler.hasCameraPermission() + // Just verifying the interface works + } + + @Test + fun testPermissionHandlerRecomposition() = runComposeUiTest { + var recompositionCount = 0 + var handler1: PermissionHandler? = null + var handler2: PermissionHandler? = null + + setContent { + recompositionCount++ + if (recompositionCount == 1) { + handler1 = getPermissionHandler() + } else { + handler2 = getPermissionHandler() + } + } + + waitForIdle() + + // Force recomposition by updating content + setContent { + recompositionCount++ + handler2 = getPermissionHandler() + } + + waitForIdle() + + // Both handlers should be non-null and functional + assertNotNull(handler1) + assertNotNull(handler2) + } +} diff --git a/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractIosTest.kt b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractIosTest.kt new file mode 100644 index 0000000..e37992a --- /dev/null +++ b/scan-engine/src/iosTest/kotlin/de/tillhub/scanengine/camera/contract/CameraScanContractIosTest.kt @@ -0,0 +1,182 @@ +package de.tillhub.scanengine.camera.contract + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import de.tillhub.scanengine.data.ScannerEvent +import kotlinx.cinterop.ExperimentalForeignApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class, ExperimentalForeignApi::class) +internal class CameraScanContractIosTest { + + @Test + fun testRememberCameraScanLauncherCreatesContract() = runComposeUiTest { + val events = mutableListOf() + val onResult: (ScannerEvent) -> Unit = { event -> + events.add(event) + } + + var contract: CameraScanContract? = null + + setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + waitForIdle() + + // Verify the contract is created + assertNotNull(contract) + } + + @Test + fun testCameraScanContractLaunchWithScanKey() = runComposeUiTest { + val events = mutableListOf() + val onResult: (ScannerEvent) -> Unit = { event -> + events.add(event) + } + + var contract: CameraScanContract? = null + val testScanKey = "test_scan_key_123" + + setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + waitForIdle() + + // Launch camera scanner with scan key + assertNotNull(contract) + contract.launchCameraScanner(testScanKey) + + // Verify that InProgress event was triggered + assertTrue(events.isNotEmpty()) + val inProgressEvent = events.find { it is ScannerEvent.Camera.InProgress } + assertNotNull(inProgressEvent) + assertTrue(inProgressEvent is ScannerEvent.Camera.InProgress) + assertEquals(testScanKey, inProgressEvent.scanKey) + } + + @Test + fun testCameraScanContractLaunchWithoutScanKey() = runComposeUiTest { + val events = mutableListOf() + val onResult: (ScannerEvent) -> Unit = { event -> + events.add(event) + } + + var contract: CameraScanContract? = null + + setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + waitForIdle() + + // Launch camera scanner without scan key (null) + assertNotNull(contract) + contract.launchCameraScanner(null) + + // Verify that InProgress event was triggered with null scan key + assertTrue(events.isNotEmpty()) + val inProgressEvent = events.find { it is ScannerEvent.Camera.InProgress } + assertNotNull(inProgressEvent) + assertTrue(inProgressEvent is ScannerEvent.Camera.InProgress) + assertEquals(null, inProgressEvent.scanKey) + } + + @Test + fun testCameraScanContractRecomposition() = runComposeUiTest { + var recompositionCount = 0 + val onResult: (ScannerEvent) -> Unit = { } + + setContent { + recompositionCount++ + + val contract = rememberCameraScanLauncher(onResult = onResult) + + // Contract should be remembered across recompositions + assertNotNull(contract) + } + + // Wait for composition to settle + waitForIdle() + + // Verify initial composition happened + assertTrue(recompositionCount >= 1) + + // Force another composition cycle + waitForIdle() + + // Contract should remain stable across recompositions + // (This tests the remember behavior) + assertTrue(recompositionCount >= 1) + } + + @Test + fun testMultipleLaunchCalls() = runComposeUiTest { + val events = mutableListOf() + val onResult: (ScannerEvent) -> Unit = { event -> + events.add(event) + } + + var contract: CameraScanContract? = null + + setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + waitForIdle() + + // Launch multiple times with different scan keys + assertNotNull(contract) + contract.launchCameraScanner("first_launch") + contract.launchCameraScanner("second_launch") + contract.launchCameraScanner(null) + + // Verify all launches triggered InProgress events + val inProgressEvents = events.filterIsInstance() + assertEquals(3, inProgressEvents.size) + + // Verify the scan keys are correct + assertEquals("first_launch", inProgressEvents[0].scanKey) + assertEquals("second_launch", inProgressEvents[1].scanKey) + assertEquals(null, inProgressEvents[2].scanKey) + } + + @Test + fun testContractCallbackHandling() = runComposeUiTest { + var latestEvent: ScannerEvent? = null + var eventCount = 0 + + val onResult: (ScannerEvent) -> Unit = { event -> + latestEvent = event + eventCount++ + } + + var contract: CameraScanContract? = null + + setContent { + contract = rememberCameraScanLauncher(onResult = onResult) + } + + // Wait for initial composition + waitForIdle() + + // Verify contract is created but no events fired yet + assertNotNull(contract) + assertEquals(0, eventCount) + + // Manually trigger launch to test functionality + contract.launchCameraScanner("callback_test") + + // Wait for composition updates + waitForIdle() + + // Verify event was captured + assertNotNull(latestEvent) + assertTrue(latestEvent is ScannerEvent.Camera.InProgress) + assertEquals(1, eventCount) + } +}