diff --git a/.gitignore b/.gitignore index 2294e4c..3cca52f 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,47 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +# Claude Code local settings +.claude/settings.local.json + +# Xcode / iOS +## User-specific settings +xcuserdata/ +*.xcuserstate +## Legacy per-user project settings (keep default.* variants) +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +## Source control metadata +*.xccheckout +*.xcscmblueprint +## Misc +*.moved-aside +*.hmap +DerivedData/ + +# iOS build artifacts +*.ipa +*.xcarchive +*.dSYM +*.dSYM.zip + +# CocoaPods +# Pods/ and Podfile.lock are intentionally NOT ignored — treat them like package-lock.json +# Uncomment below only if your team explicitly does not commit Pods: +# Pods/ + +# Swift Package Manager +.build/ +.swiftpm/ +# Package.resolved is intentionally NOT ignored — it is a lockfile for reproducible builds + +# Epson iOS SDK — binary artifact, not committed to git +# Download script: see print-plugins/epson/nativeInterop/README.md +print-plugins/epson/nativeInterop/libs/ diff --git a/README.md b/README.md index b7847f3..01e24c8 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,86 @@ -[![](https://jitpack.io/v/tillhub/print-engine.svg)](https://jitpack.io/#tillhub/print-engine) -[![API](https://img.shields.io/badge/API-21%2B-green.svg?style=flat)](https://android-arsenal.com/api?level-11) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.tillhub/print-engine-core.svg)](https://central.sonatype.com/artifact/io.github.tillhub/print-engine-core) +[![API](https://img.shields.io/badge/API-24%2B-green.svg?style=flat)](https://android-arsenal.com/api?level=24) # Print Engine This library combines various printer implementations into a single, easy-to-use interface. +Built as a Kotlin Multiplatform (KMP) library, it supports both Android and iOS platforms. + +### Supported Platforms + +| Platform | Built-in Printers | External Printers | +|----------|------------------|-------------------| +| Android | PAX, Sunmi, Verifone | Star, Epson | +| iOS | — | Star, Epson | # How to setup -**Step 1.** Add the JitPack repository to your `settings.gradle` file: +**Step 1.** Add Maven Central to your repository list: -```groovy +```kotlin +// settings.gradle.kts dependencyResolutionManagement { repositories { - ... mavenCentral() - maven { url 'https://jitpack.io' } } } ``` -**Step 2.** Add the dependency to your app `build.gradle`: +**Step 2.** Add the core dependency and the plugins you need: -```groovy +```kotlin +// build.gradle.kts dependencies { - implementation 'com.github.tillhub:print-engine:x.x.x' + // Core library (required) + implementation("io.github.tillhub:print-engine-core:3.0.5") + + // Built-in printer plugins (Android only) + implementation("io.github.tillhub:print-engine-pax:3.0.5") + implementation("io.github.tillhub:print-engine-sunmi:3.0.5") + implementation("io.github.tillhub:print-engine-verifone:3.0.5") + + // External printer plugins (Android & iOS) + implementation("io.github.tillhub:print-engine-star:3.0.5") + implementation("io.github.tillhub:print-engine-epson:3.0.5") } ``` -**Step 3.** Enable JNI legacy packaging +**Step 3.** If you use `PrintCommand.Image`, add Compose UI since `ImageBitmap` is part of the API: -```groovy -packaging { - jniLibs { - useLegacyPackaging = true +```kotlin +// build.gradle.kts +plugins { + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose") +} + +dependencies { + implementation(compose.ui) +} +``` + +**Step 4.** Enable JNI legacy packaging (Android only): + +```kotlin +// build.gradle.kts +android { + packaging { + jniLibs { + useLegacyPackaging = true + } } } ``` # Usage -This SDK offers supports 2 types printing devices: +This SDK offers supports the following printing devices: * PAX devices with a build in printer (A920, A920 pro, etc.) * Sunmi devices with a build in printer (V2, V2 PRO, V2s, etc.) * Verifone devices with a build in printer +* Star Micronics external printers (via StarXpand SDK) +* Epson external printers (via ePOS2 SDK) For devices without printer support the SDK defaults to an `EmulatedPrinter` implementation that prints into Logcat, this way it is easy to develop on emulators and unsupported devices. @@ -118,7 +155,7 @@ List of commands: ```kotlin PrintCommand.Text(val text : String) -PrintCommand.Image(val image : Bitmap) +PrintCommand.Image(val image : ImageBitmap) PrintCommand.Barcode(val barcode : String) PrintCommand.QrCode(val code : String) PrintCommand.RawData(val data : RawPrinterData) @@ -192,14 +229,14 @@ override fun onCreate(savedInstanceState: Bundle?) { val barcodeEncoder = printEngine.barcodeEncoder - val qrCode: Bitmap? = barcodeEncoder.encodeAsBitmap( + val qrCode: ImageBitmap? = barcodeEncoder.encodeAsBitmap( content = "barcode_content", type = BarcodeType.QR_CODE, imgWidth = 500, imgHeight = 500 ) - val code128: Bitmap? = barcodeEncoder.encodeAsBitmap( + val code128: ImageBitmap? = barcodeEncoder.encodeAsBitmap( content = "barcode_content", type = BarcodeType.CODE_128, imgWidth = 500, @@ -213,14 +250,13 @@ override fun onCreate(savedInstanceState: Bundle?) { ### Star Printer Library A library for integrating Star Micronics printers using the StarXpand SDK. +Supports Android and iOS. #### Installation -Add to your `build.gradle` - - ```gradle - implementation 'com.tillhub.printengine:star-printer:1.0.0' - ``` +```kotlin +implementation("io.github.tillhub:print-engine-star:3.0.5") +``` #### Setup & Usage @@ -270,7 +306,7 @@ Add to your `build.gradle` if (permissions.isNotEmpty()) { launcher.launch(permissions.toTypedArray()) - } + } ``` To avoid the USB connection permission dialog appearing every time a cable is connected or disconnected, add the following `` and `` entries to your @@ -282,7 +318,7 @@ Add to your `build.gradle` ``` -2. **Discovery** +2. **Discovery** To begin discovering Star printers, use the following method and handle the results accordingly: ```kotlin @@ -291,7 +327,7 @@ Add to your `build.gradle` // Handle discoveryState result } ``` -3. **Initialization & Printing** +3. **Initialization & Printing** Once a user selects a Star printer, initialize and start printer using the following approach: ```kotlin @@ -303,21 +339,23 @@ Add to your `build.gradle` printerEngine.initPrinter(service) printerEngine.printer.startPrintJob(printJob) ``` + ### Epson Printer Library A library for integrating Epson printers using the ePOS2 lib. +Supports Android and iOS. -#### Installation +> **Note:** On iOS, the Epson ePOS2 SDK (~68 MB) is automatically downloaded on the first build. -Add to your `build.gradle` +#### Installation - ```gradle - implementation 'com.tillhub.printengine:epson-printer:1.0.0' - ``` +```kotlin +implementation("io.github.tillhub:print-engine-epson:3.0.5") +``` #### Setup & Usage -1. **Permissions** +1. **Permissions (Android)** These permissions enable Bluetooth communication and scanning to discover and connect to nearby devices, add the following permissions check logic: ```kotlin @@ -363,7 +401,7 @@ Add to your `build.gradle` if (permissions.isNotEmpty()) { launcher.launch(permissions.toTypedArray()) - } + } ``` To avoid the USB connection permission dialog appearing every time a cable is connected or disconnected, add the following `` and `` entries to your @@ -381,7 +419,20 @@ Add to your `build.gradle` android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" android:resource="@xml/accessory_filter" /> ``` -2. **Discovery** + +2. **Permissions (iOS)** + Add the following keys to your `Info.plist`: + + ```xml + NSBluetoothAlwaysUsageDescription + Required for Bluetooth printer communication + UISupportedExternalAccessoryProtocols + + com.epson.escpos + + ``` + +3. **Discovery** To begin discovering Epson printers, use the following method and handle the results accordingly: ```kotlin @@ -390,7 +441,7 @@ Add to your `build.gradle` // Handle discoveryState result } ``` -3. **Initialization & Printing** +4. **Initialization & Printing** Once a user selects a Epson printer, initialize and start printer using the following approach: ```kotlin @@ -402,6 +453,20 @@ Add to your `build.gradle` printerEngine.initPrinter(service) printerEngine.printer.startPrintJob(printJob) ``` + +## Architecture + +``` +print-engine/ Core KMP module (commonMain, androidMain, iosMain) +print-plugins/ + pax/ PAX built-in printer (Android only) + sunmi/ Sunmi built-in printer (Android only) + verifone/ Verifone built-in printer (Android only) + star/ Star Micronics external printer (Android & iOS) + epson/ Epson external printer (Android & iOS) +sample/ Demo app (Android & iOS) +``` + ## Credits - [Đorđe Hrnjez](https://github.com/djordjeh) diff --git a/gradle.properties b/gradle.properties index 9a1176b..e4e17e6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,7 @@ kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M kotlin.mpp.androidSourceSetLayoutVersion=2 +kotlin.mpp.enableCInteropCommonization=true #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a1c33d4..7a69554 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -print-engine = "3.0.3" +print-engine = "3.0.5" # Build tools kotlin = "2.2.0" diff --git a/iosApp/029578.xcodeproj/project.pbxproj b/iosApp/029578.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2b31937 --- /dev/null +++ b/iosApp/029578.xcodeproj/project.pbxproj @@ -0,0 +1,304 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 1D6E0B0A2B0A00000001 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6E0B092B0A00000001 /* iOSApp.swift */; }; + 1D6E0B0C2B0A00000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6E0B0B2B0A00000002 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1D6E0B062B0A00000000 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1D6E0B092B0A00000001 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + 1D6E0B0B2B0A00000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1D6E0B0D2B0A00000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1D6E0B032B0A00000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1D6E0AFD2B0A00000000 = { + isa = PBXGroup; + children = ( + 1D6E0B082B0A00000000 /* iosApp */, + 1D6E0B072B0A00000000 /* Products */, + ); + sourceTree = ""; + }; + 1D6E0B072B0A00000000 /* Products */ = { + isa = PBXGroup; + children = ( + 1D6E0B062B0A00000000 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; + 1D6E0B082B0A00000000 /* iosApp */ = { + isa = PBXGroup; + children = ( + 1D6E0B092B0A00000001 /* iOSApp.swift */, + 1D6E0B0B2B0A00000002 /* ContentView.swift */, + 1D6E0B0D2B0A00000003 /* Info.plist */, + ); + path = iosApp; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1D6E0B052B0A00000000 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1D6E0B142B0A00000000 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + 1D6E0B022B0A00000000 /* Compile Kotlin Framework */, + 1D6E0B012B0A00000000 /* Sources */, + 1D6E0B032B0A00000000 /* Frameworks */, + 1D6E0B042B0A00000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + productName = iosApp; + productReference = 1D6E0B062B0A00000000 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1D6E0AFE2B0A00000000 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + }; + buildConfigurationList = 1D6E0B012B0A00000000x /* Build configuration list for PBXProject "029578" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1D6E0AFD2B0A00000000; + productRefGroup = 1D6E0B072B0A00000000 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1D6E0B052B0A00000000 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1D6E0B042B0A00000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1D6E0B022B0A00000000 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export JAVA_HOME=\"/Applications/Android Studio.app/Contents/jbr/Contents/Home\"\nexport PATH=\"$JAVA_HOME/bin:$PATH\"\ncd \"$SRCROOT/..\"\n./gradlew :sample:embedAndSignAppleFrameworkForXcode --no-configuration-cache\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1D6E0B012B0A00000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1D6E0B0A2B0A00000001 /* iOSApp.swift in Sources */, + 1D6E0B0C2B0A00000002 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1D6E0B122B0A00000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1D6E0B132B0A00000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_OPTIMIZATION_LEVEL = s; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1D6E0B152B0A00000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YY9L92XV28; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/../print-plugins/epson/nativeInterop/libs/ios/libepos2.xcframework/ios-arm64_armv7"; + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(SRCROOT)/../print-plugins/epson/nativeInterop/libs/ios/libepos2.xcframework/ios-arm64_i386_x86_64-simulator"; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + "-framework", + CoreBluetooth, + "-framework", + ExternalAccessory, + "-lepos2", + "-lxml2", + ); + PRODUCT_BUNDLE_IDENTIFIER = de.tillhub.printengine.sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1D6E0B162B0A00000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YY9L92XV28; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/../print-plugins/epson/nativeInterop/libs/ios/libepos2.xcframework/ios-arm64_armv7"; + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(SRCROOT)/../print-plugins/epson/nativeInterop/libs/ios/libepos2.xcframework/ios-arm64_i386_x86_64-simulator"; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + "-framework", + CoreBluetooth, + "-framework", + ExternalAccessory, + "-lepos2", + "-lxml2", + ); + PRODUCT_BUNDLE_IDENTIFIER = de.tillhub.printengine.sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1D6E0B012B0A00000000x /* Build configuration list for PBXProject "029578" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D6E0B122B0A00000000 /* Debug */, + 1D6E0B132B0A00000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1D6E0B142B0A00000000 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D6E0B152B0A00000000 /* Debug */, + 1D6E0B162B0A00000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1D6E0AFE2B0A00000000 /* Project object */; +} diff --git a/iosApp/029578.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/029578.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iosApp/029578.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..94dd236 --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,18 @@ +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(.all) + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..2fca73a --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,28 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + NSBluetoothAlwaysUsageDescription + Required to discover and connect to Epson printers via Bluetooth. + NSLocalNetworkUsageDescription + Required to discover and connect to Epson printers on the local network. + NSBonjourServices + + _printer._tcp + _pdl-datastream._tcp + _ipp._tcp + _ipps._tcp + + UISupportedExternalAccessoryProtocols + + com.epson.escpos + + + diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..927e0b9 --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/print-engine/build.gradle.kts b/print-engine/build.gradle.kts index 416d025..9c641fa 100644 --- a/print-engine/build.gradle.kts +++ b/print-engine/build.gradle.kts @@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) alias(libs.plugins.mokkery) alias(libs.plugins.maven.publish) } @@ -44,6 +46,9 @@ kotlin { dependencies { implementation(libs.kotlin.coroutines) + // Compose UI for cross-platform ImageBitmap + implementation(compose.ui) + // Utils implementation(libs.kermit) } diff --git a/print-engine/src/androidMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.android.kt b/print-engine/src/androidMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.android.kt index 3ad4672..b4d82e2 100644 --- a/print-engine/src/androidMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.android.kt +++ b/print-engine/src/androidMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.android.kt @@ -1,5 +1,7 @@ package de.tillhub.printengine.barcode +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.createBitmap import co.touchlab.kermit.Logger import com.google.zxing.BarcodeFormat @@ -7,7 +9,6 @@ import com.google.zxing.EncodeHintType import com.google.zxing.MultiFormatWriter import com.google.zxing.common.BitMatrix import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -import de.tillhub.printengine.data.ImageBitmap internal actual class BarcodeEncoderImpl : BarcodeEncoder { actual override fun encodeAsBitmap( @@ -52,7 +53,7 @@ internal actual class BarcodeEncoderImpl : BarcodeEncoder { return createBitmap(width, height).apply { setPixels(pixels, 0, width, 0, 0, width, height) - } + }.asImageBitmap() } private fun typeConverter(type: BarcodeType): BarcodeFormat = when (type) { diff --git a/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt b/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt index e736d39..22c14cf 100644 --- a/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt +++ b/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt @@ -2,12 +2,12 @@ package de.tillhub.printengine.data import android.graphics.Bitmap import android.util.Base64 +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap import java.io.ByteArrayOutputStream -actual typealias ImageBitmap = Bitmap - actual fun ImageBitmap.encodeToBase64(): String = ByteArrayOutputStream().let { stream -> - this.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, stream) + this.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, stream) val b = stream.toByteArray() Base64.encodeToString(b, Base64.DEFAULT) } diff --git a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/PrinterImplWImageTest.kt b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/PrinterImplWImageTest.kt index c4ff981..1cc7903 100644 --- a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/PrinterImplWImageTest.kt +++ b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/PrinterImplWImageTest.kt @@ -1,6 +1,8 @@ package de.tillhub.printengine import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import de.tillhub.printengine.analytics.PrintAnalytics import de.tillhub.printengine.data.PrintCommand import de.tillhub.printengine.data.PrintJob @@ -29,6 +31,8 @@ internal class PrinterImplWImageTest : lateinit var bitmap: Bitmap lateinit var footerBitmap: Bitmap + lateinit var imageBitmap: ImageBitmap + lateinit var footerImageBitmap: ImageBitmap lateinit var controller: PrinterController lateinit var service: PrintService lateinit var analytics: PrintAnalytics @@ -37,6 +41,8 @@ internal class PrinterImplWImageTest : beforeSpec { bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) footerBitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + imageBitmap = bitmap.asImageBitmap() + footerImageBitmap = footerBitmap.asImageBitmap() } beforeTest { @@ -80,6 +86,7 @@ internal class PrinterImplWImageTest : afterSpec { bitmap.recycle() + footerBitmap.recycle() } describe("printer enabled") { @@ -91,7 +98,7 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap), + PrintCommand.Image(imageBitmap), PrintCommand.Text("receipt_to_print"), ), ), @@ -100,7 +107,7 @@ internal class PrinterImplWImageTest : coVerify(ordering = Ordering.ORDERED) { controller.setIntensity(PrintingIntensity.DEFAULT) controller.setFontSize(PrintingFontType.DEFAULT_FONT_SIZE) - controller.printImage(bitmap) + controller.printImage(imageBitmap) controller.printText("receipt_to_print") controller.start() analytics.logPrintReceipt( @@ -114,11 +121,11 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap), + PrintCommand.Image(imageBitmap), PrintCommand.Text("raw_receipt_text"), PrintCommand.QrCode("signature_qr_code"), PrintCommand.Barcode("barcode"), - PrintCommand.Image(footerBitmap), + PrintCommand.Image(footerImageBitmap), PrintCommand.FeedPaper, ), ), @@ -127,11 +134,11 @@ internal class PrinterImplWImageTest : coVerify(ordering = Ordering.ORDERED) { controller.setIntensity(PrintingIntensity.DEFAULT) controller.setFontSize(PrintingFontType.DEFAULT_FONT_SIZE) - controller.printImage(bitmap) + controller.printImage(imageBitmap) controller.printText("raw_receipt_text") controller.printQr("signature_qr_code") controller.printBarcode("barcode") - controller.printImage(footerBitmap) + controller.printImage(footerImageBitmap) controller.feedPaper() controller.start() analytics.logPrintReceipt( @@ -155,11 +162,11 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap), + PrintCommand.Image(imageBitmap), PrintCommand.Text("raw_receipt_text"), PrintCommand.QrCode("signature_qr_code"), PrintCommand.Barcode("barcode"), - PrintCommand.Image(footerBitmap), + PrintCommand.Image(footerImageBitmap), PrintCommand.FeedPaper, ), ), diff --git a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImplTest.kt b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImplTest.kt index 6cc3d4e..d2a6b71 100644 --- a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImplTest.kt +++ b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImplTest.kt @@ -1,5 +1,6 @@ package de.tillhub.printengine.barcode +import androidx.compose.ui.graphics.asAndroidBitmap import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.MultiFormatWriter @@ -32,7 +33,7 @@ internal class BarcodeEncoderImplTest : val height = expectedResult.height for (y in 0 until height) { for (x in 0 until width) { - result?.getPixel(x, y) shouldBe if (expectedResult.get(x, y)) BLACK else WHITE + result?.asAndroidBitmap()?.getPixel(x, y) shouldBe if (expectedResult.get(x, y)) BLACK else WHITE } } } @@ -40,7 +41,7 @@ internal class BarcodeEncoderImplTest : test("encodeAsBitmap barcode compare bitmap") { val result = barcodeEncoder.encodeAsBitmap("RTB183648B", BarcodeType.CODE_128, 435, 140) - BitmapAssertions.assertBitmapMatches("barcode", result!!) + BitmapAssertions.assertBitmapMatches("barcode", result!!.asAndroidBitmap()) } test("encodeAsBitmap qr code") { @@ -61,7 +62,7 @@ internal class BarcodeEncoderImplTest : val height = expectedResult.height for (y in 0 until height) { for (x in 0 until width) { - result?.getPixel(x, y) shouldBe if (expectedResult.get(x, y)) BLACK else WHITE + result?.asAndroidBitmap()?.getPixel(x, y) shouldBe if (expectedResult.get(x, y)) BLACK else WHITE } } } @@ -69,7 +70,7 @@ internal class BarcodeEncoderImplTest : test("encodeAsBitmap qr code compare bitmap") { val result = barcodeEncoder.encodeAsBitmap("123ABC", BarcodeType.QR_CODE, 220, 220) - BitmapAssertions.assertBitmapMatches("qr", result!!) + BitmapAssertions.assertBitmapMatches("qr", result!!.asAndroidBitmap()) } }) { companion object { diff --git a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt index f7495bd..ea17128 100644 --- a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt +++ b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt @@ -1,6 +1,7 @@ package de.tillhub.printengine.html import android.graphics.Bitmap +import androidx.compose.ui.graphics.asImageBitmap import io.kotest.core.spec.style.FunSpec import io.kotest.extensions.robolectric.RobolectricTest import io.kotest.matchers.shouldBe @@ -10,7 +11,7 @@ internal class HtmlUtilsImageTest : FunSpec({ test("generateImageHtml") { val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) - val result = HtmlUtils.generateImageHtml(bitmap) + val result = HtmlUtils.generateImageHtml(bitmap.asImageBitmap()) bitmap.recycle() result shouldBe "
" + diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterController.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterController.kt index 12b412b..195140b 100644 --- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterController.kt +++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterController.kt @@ -1,6 +1,6 @@ package de.tillhub.printengine -import de.tillhub.printengine.data.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap import de.tillhub.printengine.data.PrinterInfo import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.PrintingFontType diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoder.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoder.kt index bade036..cb8d7d6 100644 --- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoder.kt +++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoder.kt @@ -1,6 +1,6 @@ package de.tillhub.printengine.barcode -import de.tillhub.printengine.data.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap interface BarcodeEncoder { fun encodeAsBitmap( diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.kt index 50aeb01..dba30f0 100644 --- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.kt +++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.kt @@ -1,6 +1,6 @@ package de.tillhub.printengine.barcode -import de.tillhub.printengine.data.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap internal expect class BarcodeEncoderImpl() : BarcodeEncoder { override fun encodeAsBitmap( diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmap.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmapExt.kt similarity index 64% rename from print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmap.kt rename to print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmapExt.kt index 7977b01..90d70e9 100644 --- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmap.kt +++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmapExt.kt @@ -1,5 +1,5 @@ package de.tillhub.printengine.data -expect class ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap expect fun ImageBitmap.encodeToBase64(): String diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrintCommand.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrintCommand.kt index 6d362ad..a1b9a55 100644 --- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrintCommand.kt +++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrintCommand.kt @@ -1,6 +1,6 @@ package de.tillhub.printengine.data -import de.tillhub.printengine.data.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap import de.tillhub.printengine.helpers.HashHelper sealed class PrintCommand { @@ -33,7 +33,7 @@ sealed class PrintCommand { class Barcode( val barcode: String, ) : PrintCommand() { - override fun toString() = "PrintCommand.Text(" + + override fun toString() = "PrintCommand.Barcode(" + "barcode=$barcode" + ")" diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt index bf34327..81962bc 100644 --- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt +++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt @@ -1,9 +1,9 @@ package de.tillhub.printengine.html +import androidx.compose.ui.graphics.ImageBitmap import de.tillhub.printengine.PrinterController import de.tillhub.printengine.barcode.BarcodeEncoder import de.tillhub.printengine.barcode.BarcodeType -import de.tillhub.printengine.data.ImageBitmap import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.RawPrinterData import de.tillhub.printengine.html.HtmlUtils.generateImageHtml diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlUtils.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlUtils.kt index d681da7..c5b21d7 100644 --- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlUtils.kt +++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlUtils.kt @@ -1,6 +1,6 @@ package de.tillhub.printengine.html -import de.tillhub.printengine.data.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap import de.tillhub.printengine.data.encodeToBase64 object HtmlUtils { diff --git a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/PrinterServiceProvider.ios.kt b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/PrinterServiceProvider.ios.kt index 0b87de4..60a3951 100644 --- a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/PrinterServiceProvider.ios.kt +++ b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/PrinterServiceProvider.ios.kt @@ -1,5 +1,11 @@ package de.tillhub.printengine +import de.tillhub.printengine.barcode.BarcodeEncoder +import de.tillhub.printengine.data.ExternalPrinter + actual interface PrinterServiceProvider { - // TODO("not implemented") + fun build( + printer: ExternalPrinter? = null, + barcode: BarcodeEncoder? = null, + ): PrintService } diff --git a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.ios.kt b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.ios.kt index c80d1f1..bf2c6df 100644 --- a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.ios.kt +++ b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.ios.kt @@ -1,14 +1,128 @@ package de.tillhub.printengine.barcode -import de.tillhub.printengine.data.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import co.touchlab.kermit.Logger +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.CoreGraphics.CGAffineTransformMakeScale +import platform.CoreGraphics.CGRectGetHeight +import platform.CoreGraphics.CGRectGetWidth +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSizeMake +import platform.CoreImage.CIFilter +import platform.Foundation.NSClassFromString +import platform.Foundation.NSData +import platform.Foundation.NSSelectorFromString +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.dataUsingEncoding +import platform.Foundation.setValue +import platform.UIKit.UIGraphicsBeginImageContextWithOptions +import platform.UIKit.UIGraphicsEndImageContext +import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext +import platform.UIKit.UIImage +import platform.UIKit.UIImagePNGRepresentation +import platform.darwin.NSObject +import platform.posix.memcpy +import org.jetbrains.skia.Image as SkiaImage internal actual class BarcodeEncoderImpl : BarcodeEncoder { + @OptIn(ExperimentalForeignApi::class) actual override fun encodeAsBitmap( content: String, type: BarcodeType, imgWidth: Int, imgHeight: Int, ): ImageBitmap? { - TODO("Not yet implemented") + val filterName = when (type) { + BarcodeType.QR_CODE -> "CIQRCodeGenerator" + BarcodeType.CODE_128 -> "CICode128BarcodeGenerator" + } + + val filter = createCIFilter(filterName) ?: run { + Logger.e("Failed to create CIFilter: $filterName") + return null + } + + val messageData = (content as NSString).dataUsingEncoding(NSUTF8StringEncoding) + ?: return null + filter.setValue(messageData, forKey = "inputMessage") + + if (type == BarcodeType.QR_CODE) { + filter.setValue("Q", forKey = "inputCorrectionLevel") + } + + val outputImage = filter.outputImage ?: run { + Logger.e("CIFilter produced no output image") + return null + } + + // Scale to desired dimensions + val extent = outputImage.extent + val extentWidth = CGRectGetWidth(extent) + val extentHeight = CGRectGetHeight(extent) + if (extentWidth <= 0.0 || extentHeight <= 0.0) return null + + val scaleX = imgWidth.toDouble() / extentWidth + val scaleY = imgHeight.toDouble() / extentHeight + val scaledImage = outputImage.imageByApplyingTransform( + CGAffineTransformMakeScale(scaleX, scaleY), + ) + + // Render CIImage to a bitmap-backed UIImage + val ciBasedImage = UIImage(cIImage = scaledImage) + val targetSize = CGSizeMake(imgWidth.toDouble(), imgHeight.toDouble()) + + UIGraphicsBeginImageContextWithOptions(targetSize, true, 1.0) + val renderedImage = try { + ciBasedImage.drawInRect(CGRectMake(0.0, 0.0, imgWidth.toDouble(), imgHeight.toDouble())) + UIGraphicsGetImageFromCurrentImageContext() + } finally { + UIGraphicsEndImageContext() + } + + val pngData = renderedImage?.let { UIImagePNGRepresentation(it) } ?: run { + Logger.e("Failed to create PNG representation") + return null + } + + // Convert NSData → ByteArray → Skia Image → Compose ImageBitmap + val bytes = pngData.toByteArray() + return try { + val skiaImage = SkiaImage.makeFromEncoded(bytes) + try { + skiaImage.toComposeImageBitmap() + } finally { + skiaImage.close() + } + } catch (e: Exception) { + Logger.e("Failed to decode barcode image", e) + null + } + } + + /** + * Creates a CIFilter via ObjC runtime +[CIFilter filterWithName:]. + * K/N bindings only expose CIFilter() and CIFilter(coder:) constructors, + * so we must call the factory class method via performSelector. + */ + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + @Suppress("UNCHECKED_CAST") + private fun createCIFilter(name: String): CIFilter? { + val cls = NSClassFromString("CIFilter") as? NSObject ?: return null + val sel = NSSelectorFromString("filterWithName:") + return cls.performSelector(sel, withObject = name) as? CIFilter + } + + @OptIn(ExperimentalForeignApi::class) + private fun NSData.toByteArray(): ByteArray = ByteArray(length.toInt()).apply { + if (isNotEmpty()) { + usePinned { pinned -> + memcpy(pinned.addressOf(0), bytes, length) + } + } } } diff --git a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt index 7b67fd6..c8527ee 100644 --- a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt +++ b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt @@ -1,7 +1,59 @@ package de.tillhub.printengine.data -actual class ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.ImageInfo +import platform.posix.memcpy +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import org.jetbrains.skia.Bitmap as SkiaBitmap +import org.jetbrains.skia.Image as SkiaImage +/** + * Encodes a Compose [ImageBitmap] to PNG bytes via Skia. + * Handles pixel extraction, Skia bitmap/image lifecycle, and cleanup. + * Returns null if PNG encoding fails. + */ +@OptIn(ExperimentalForeignApi::class) +fun ImageBitmap.encodeToPngBytes(): ByteArray? { + val w = width + val h = height + val buffer = IntArray(w * h) + readPixels(buffer) + + // On little-endian (all Apple platforms), raw int bytes are already in + // BGRA order which matches Skia's N32 native format — memcpy directly. + val bytes = ByteArray(buffer.size * 4) + buffer.usePinned { src -> + bytes.usePinned { dst -> + memcpy(dst.addressOf(0), src.addressOf(0), bytes.size.toULong()) + } + } + + val imageInfo = ImageInfo.makeN32Premul(w, h) + val skiaBitmap = SkiaBitmap().apply { + allocPixels(imageInfo) + installPixels(imageInfo, bytes, w * 4) + } + + return try { + val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap) + try { + skiaImage.encodeToData(EncodedImageFormat.PNG)?.bytes + } finally { + skiaImage.close() + } + } finally { + skiaBitmap.close() + } +} + +@OptIn(ExperimentalEncodingApi::class) actual fun ImageBitmap.encodeToBase64(): String { - TODO("Not yet implemented") + val pngBytes = encodeToPngBytes() + ?: error("Failed to encode image to PNG") + return Base64.encode(pngBytes) } diff --git a/print-plugins/epson/build.gradle.kts b/print-plugins/epson/build.gradle.kts index 6c4dffb..9a1a7e1 100644 --- a/print-plugins/epson/build.gradle.kts +++ b/print-plugins/epson/build.gradle.kts @@ -1,19 +1,85 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.konan.target.KonanTarget plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) alias(libs.plugins.maven.publish) } +object EpsonSdk { + const val VERSION = "2.23.1" + const val REPO = "https://github.com/popina/EpsonSDK.git" + const val FRAMEWORK_NAME = "print-engine-epson" + + // DEVICE_SLICE → all physical iPhones/iPads (arm64 + legacy armv7) + // SIMULATOR_SLICE → Xcode Simulator on Intel & Apple Silicon Macs + const val DEVICE_SLICE = "ios-arm64_armv7" + const val SIMULATOR_SLICE = "ios-arm64_i386_x86_64-simulator" + + fun sliceFor(target: KonanTarget): String = + when (target) { + KonanTarget.IOS_ARM64 -> DEVICE_SLICE + else -> SIMULATOR_SLICE + } +} + +val epsonXcframeworkDir = "${project.projectDir}/nativeInterop/libs/ios/libepos2.xcframework" + +val downloadEpsonSdk by tasks.registering { + group = "setup" + description = "Downloads Epson ePOS SDK xcframework if not already present" + outputs.dir(epsonXcframeworkDir) + onlyIf { !file(epsonXcframeworkDir).exists() } + + doLast { + val tmpDir = + layout.buildDirectory + .dir("tmp/epson-sdk") + .get() + .asFile + tmpDir.deleteRecursively() + tmpDir.mkdirs() + + logger.lifecycle("Downloading Epson ePOS SDK v${EpsonSdk.VERSION} ...") + providers + .exec { + commandLine( + "git", + "clone", + "--depth", + "1", + "--branch", + EpsonSdk.VERSION, + EpsonSdk.REPO, + tmpDir.absolutePath, + ) + }.result + .get() + + file(epsonXcframeworkDir).parentFile.mkdirs() + copy { + from("${tmpDir.absolutePath}/libepos2.xcframework") + into(epsonXcframeworkDir) + } + + tmpDir.deleteRecursively() + logger.lifecycle("Epson ePOS SDK installed at $epsonXcframeworkDir") + } +} + +tasks.configureEach { + if (name.startsWith("cinteropEpos2")) dependsOn(downloadEpsonSdk) +} + kotlin { compilerOptions { - // removes warnings for expect/actual classes freeCompilerArgs.add("-Xexpect-actual-classes") } androidTarget { - // Keep JVM target consistent with Java 17 compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } @@ -21,26 +87,45 @@ kotlin { publishLibraryVariants("release") dependencies { - // Core Dependencies implementation(libs.androidx.core) coreLibraryDesugaring(libs.android.desugarJdkLibs) - implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) - // Unit tests testImplementation(libs.bundles.testing) testImplementation(libs.bundles.robolectric) - // Android tests androidTestImplementation(libs.bundles.testing) androidTestImplementation(libs.bundles.testing.android) } } - val xcfName = "print-engine-epson" - iosX64 { binaries.framework { baseName = xcfName } } - iosArm64 { binaries.framework { baseName = xcfName } } - iosSimulatorArm64 { binaries.framework { baseName = xcfName } } + fun org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget.configureEpsonInterop() { + val slice = EpsonSdk.sliceFor(konanTarget) + binaries.framework { baseName = EpsonSdk.FRAMEWORK_NAME } + compilations["main"].cinterops { + create("epos2") { + definitionFile = file("nativeInterop/cinterop/epos2.def") + includeDirs( + file("nativeInterop/cinterop/shim"), + "$epsonXcframeworkDir/$slice/Headers", + ) + } + } + binaries.all { + linkerOpts( + "-L$epsonXcframeworkDir/$slice", + "-lepos2", + "-framework", + "CoreBluetooth", + "-framework", + "ExternalAccessory", + ) + } + } + + iosArm64 { configureEpsonInterop() } + iosX64 { configureEpsonInterop() } + iosSimulatorArm64 { configureEpsonInterop() } sourceSets { val commonMain by getting { @@ -48,6 +133,7 @@ kotlin { implementation(project(":print-engine")) implementation(libs.kotlin.coroutines) + implementation(compose.ui) // Utils implementation(libs.kermit) diff --git a/print-plugins/epson/nativeInterop/README.md b/print-plugins/epson/nativeInterop/README.md new file mode 100644 index 0000000..0017b55 --- /dev/null +++ b/print-plugins/epson/nativeInterop/README.md @@ -0,0 +1,39 @@ +# Epson iOS SDK Setup + +The `libepos2.xcframework` binary is **not committed to git** due to its size (~71 MB). + +## Automatic download + +The SDK is downloaded automatically on first iOS build. No manual steps needed: + +```bash +./gradlew :print-plugins:epson:cinteropEpos2IosArm64 +``` + +Or simply build the project — any task that depends on cinterop will trigger the download. + +To re-download (e.g. after version bump), delete the cached copy: + +```bash +rm -rf print-plugins/epson/nativeInterop/libs/ios/libepos2.xcframework +``` + +## Manual download + +If you prefer to download manually: + +```bash +git clone --depth 1 --branch 2.23.1 https://github.com/popina/EpsonSDK.git /tmp/epson_sdk +mkdir -p print-plugins/epson/nativeInterop/libs/ios +cp -r /tmp/epson_sdk/libepos2.xcframework print-plugins/epson/nativeInterop/libs/ios/ +rm -rf /tmp/epson_sdk +``` + +## SDK version + +| SDK Version | Tested | +|-------------|--------| +| 2.23.1 | yes | + +The SDK is sourced from the CocoaPod mirror at https://github.com/popina/EpsonSDK. +Original SDK: https://download.epson-biz.com/modules/pos/index.php?page=soft&pcat=3&scat=50 \ No newline at end of file diff --git a/print-plugins/epson/nativeInterop/cinterop/epos2.def b/print-plugins/epson/nativeInterop/cinterop/epos2.def new file mode 100644 index 0000000..874f594 --- /dev/null +++ b/print-plugins/epson/nativeInterop/cinterop/epos2.def @@ -0,0 +1,3 @@ +language = Objective-C +headers = ePOS2.h +package = com.epson.epos2 diff --git a/print-plugins/epson/nativeInterop/cinterop/shim/UIKit/UIKit.h b/print-plugins/epson/nativeInterop/cinterop/shim/UIKit/UIKit.h new file mode 100644 index 0000000..da582ec --- /dev/null +++ b/print-plugins/epson/nativeInterop/cinterop/shim/UIKit/UIKit.h @@ -0,0 +1,16 @@ +/** + * UIKit shim for cinterop commonization. + * + * ePOS2.h imports but only uses UIImage. + * Importing the full UIKit framework causes the Kotlin/Native commonizer + * to crash on types that differ across iOS targets (e.g. UIMenuSystemElementGroupPreference). + * + * This shim intercepts the import and pulls in only UIImage, + * so cinterop resolves it as the real platform.UIKit.UIImage type. + */ +#ifndef EPOS2_UIKIT_SHIM_H +#define EPOS2_UIKIT_SHIM_H + +#import + +#endif /* EPOS2_UIKIT_SHIM_H */ diff --git a/print-plugins/epson/src/androidInstrumentedTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt b/print-plugins/epson/src/androidInstrumentedTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt deleted file mode 100644 index e295aa2..0000000 --- a/print-plugins/epson/src/androidInstrumentedTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package de.tillhub.printengine.epson - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import de.tillhub.printengine.data.ExternalPrinter -import io.mockk.mockk -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -// TODO this test is not working with minSdk = 24 -@RunWith(AndroidJUnit4::class) -class EpsonServiceProviderTest { - @Test - fun build_shouldReturnEpsonPrintService() { - val context: Context = ApplicationProvider.getApplicationContext() - val printer = mockk(relaxed = true) - - val result = EpsonServiceProvider.build(context, printer) - - assertTrue(result is EpsonPrintService) - } -} diff --git a/print-plugins/epson/src/androidMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.android.kt b/print-plugins/epson/src/androidMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.android.kt index 12fd83a..6d492f1 100644 --- a/print-plugins/epson/src/androidMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.android.kt +++ b/print-plugins/epson/src/androidMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.android.kt @@ -1,6 +1,7 @@ package de.tillhub.printengine.epson -import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap import com.epson.epos2.Epos2Exception import de.tillhub.printengine.PrinterController import de.tillhub.printengine.data.ExternalPrinter @@ -61,13 +62,14 @@ internal actual class EpsonPrinterController( ) } - actual override fun printImage(image: Bitmap) = executeEpsonCommand { + actual override fun printImage(image: ImageBitmap) = executeEpsonCommand { + val bitmap = image.asAndroidBitmap() printerWrapper.addImage( - image, + bitmap, IMAGE_START_XY, IMAGE_START_XY, - image.getWidth(), - image.getHeight(), + bitmap.width, + bitmap.height, EpsonPrinter.COLOR_1, EpsonPrinter.MODE_MONO, EpsonPrinter.HALFTONE_DITHER, diff --git a/print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonPrinterControllerTest.kt b/print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonPrinterControllerTest.kt index 952e98c..f1aefed 100644 --- a/print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonPrinterControllerTest.kt +++ b/print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonPrinterControllerTest.kt @@ -1,6 +1,7 @@ package de.tillhub.printengine.epson import android.graphics.Bitmap +import androidx.compose.ui.graphics.asImageBitmap import com.epson.epos2.Epos2Exception import com.epson.eposprint.Builder.BARCODE_CODE128 import com.epson.eposprint.Builder.COLOR_1 @@ -18,6 +19,7 @@ import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.PrintingFontType import de.tillhub.printengine.data.PrintingPaperSpec import io.kotest.core.spec.style.FunSpec +import io.kotest.extensions.robolectric.RobolectricTest import io.kotest.matchers.shouldBe import io.mockk.Ordering import io.mockk.Runs @@ -27,6 +29,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +@RobolectricTest class EpsonPrinterControllerTest : FunSpec({ @@ -95,11 +98,8 @@ class EpsonPrinterControllerTest : } test("printImage should call addImage with correct parameters") { - val bitmap = - mockk { - every { width } returns 100 - every { height } returns 100 - } + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val imageBitmap = bitmap.asImageBitmap() every { printerWrapper.addImage( any(), @@ -115,11 +115,11 @@ class EpsonPrinterControllerTest : ) } just Runs - controller.printImage(bitmap) + controller.printImage(imageBitmap) verify { printerWrapper.addImage( - bitmap = bitmap, + bitmap = any(), x = 0, y = 0, width = 100, diff --git a/print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt b/print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt new file mode 100644 index 0000000..f100835 --- /dev/null +++ b/print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt @@ -0,0 +1,46 @@ +package de.tillhub.printengine.epson + +import android.content.Context +import io.mockk.mockk +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class EpsonServiceProviderTest { + + private lateinit var context: Context + + @BeforeTest + fun before() { + context = mockk(relaxed = true) + } + + @Test + fun buildShouldThrowWhenPrinterIsNull() { + // When + val exception = assertFailsWith { + EpsonServiceProvider.build(context, printer = null) + } + + // Then + assertEquals( + "EpsonServiceProvider requires an ExternalPrinter configuration", + exception.message, + ) + } + + @Test + fun buildShouldThrowWhenPrinterIsNullWithBarcodeProvided() { + // When + val exception = assertFailsWith { + EpsonServiceProvider.build(context, printer = null, barcode = mockk()) + } + + // Then + assertEquals( + "EpsonServiceProvider requires an ExternalPrinter configuration", + exception.message, + ) + } +} diff --git a/print-plugins/epson/src/commonMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.kt b/print-plugins/epson/src/commonMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.kt index 107889e..a2d7c0a 100644 --- a/print-plugins/epson/src/commonMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.kt +++ b/print-plugins/epson/src/commonMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.kt @@ -1,7 +1,7 @@ package de.tillhub.printengine.epson +import androidx.compose.ui.graphics.ImageBitmap import de.tillhub.printengine.PrinterController -import de.tillhub.printengine.data.ImageBitmap import de.tillhub.printengine.data.PrinterInfo import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.PrintingFontType diff --git a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt index fe33bca..219c4f2 100644 --- a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt +++ b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt @@ -1,15 +1,128 @@ package de.tillhub.printengine.epson +import co.touchlab.kermit.Logger +import com.epson.epos2.EPOS2_MODEL_ANK +import com.epson.epos2.EPOS2_TM_H6000 +import com.epson.epos2.EPOS2_TM_L100 +import com.epson.epos2.EPOS2_TM_L90 +import com.epson.epos2.EPOS2_TM_L90LFC +import com.epson.epos2.EPOS2_TM_M10 +import com.epson.epos2.EPOS2_TM_M30 +import com.epson.epos2.EPOS2_TM_M30II +import com.epson.epos2.EPOS2_TM_M30III +import com.epson.epos2.EPOS2_TM_M50 +import com.epson.epos2.EPOS2_TM_P20 +import com.epson.epos2.EPOS2_TM_P20II +import com.epson.epos2.EPOS2_TM_P60 +import com.epson.epos2.EPOS2_TM_P60II +import com.epson.epos2.EPOS2_TM_P80 +import com.epson.epos2.EPOS2_TM_P80II +import com.epson.epos2.EPOS2_TM_T100 +import com.epson.epos2.EPOS2_TM_T20 +import com.epson.epos2.EPOS2_TM_T60 +import com.epson.epos2.EPOS2_TM_T70 +import com.epson.epos2.EPOS2_TM_T81 +import com.epson.epos2.EPOS2_TM_T82 +import com.epson.epos2.EPOS2_TM_T83 +import com.epson.epos2.EPOS2_TM_T83III +import com.epson.epos2.EPOS2_TM_T88 +import com.epson.epos2.EPOS2_TM_T88VII +import com.epson.epos2.EPOS2_TM_T90 +import com.epson.epos2.EPOS2_TM_T90KP +import com.epson.epos2.EPOS2_TM_U220 +import com.epson.epos2.EPOS2_TM_U330 +import com.epson.epos2.EPOS2_TS_100 +import com.epson.epos2.Epos2Printer +import com.epson.epos2.Epos2PrinterStatusInfo +import com.epson.epos2.Epos2PtrReceiveDelegateProtocol import de.tillhub.printengine.PrintService import de.tillhub.printengine.PrinterController +import de.tillhub.printengine.data.ExternalPrinter import de.tillhub.printengine.data.PrinterState +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import platform.darwin.NSObject -internal actual class EpsonPrintService : PrintService() { - actual override var printController: PrinterController? - get() = TODO("Not yet implemented") - set(value) {} +private val logger = Logger.withTag("EpsonPrintService") - actual override val printerState: Flow - get() = TODO("Not yet implemented") +@OptIn(ExperimentalForeignApi::class) +internal actual class EpsonPrintService( + printer: ExternalPrinter, +) : PrintService() { + + private val connectionState = MutableStateFlow(PrinterState.CheckingForPrinter) + actual override val printerState: Flow = connectionState + + private val receiveDelegate = object : NSObject(), Epos2PtrReceiveDelegateProtocol { + override fun onPtrReceive( + printerObj: Epos2Printer?, + code: Int, + status: Epos2PrinterStatusInfo?, + printJobId: String?, + ) { + connectionState.value = EpsonPrinterErrorState.epsonStatusToState(code, status) + } + } + + private val epsonPrinter: Epos2Printer by lazy { + connectionState.value = PrinterState.Preparing + val series = printer.info.deviceModel.uppercase().toModel() + logger.d { "Initializing Epos2Printer series=$series model=${printer.info.deviceModel}" } + Epos2Printer(printerSeries = series, lang = EPOS2_MODEL_ANK).apply { + setReceiveEventDelegate(receiveDelegate) + connectionState.value = PrinterState.Connected + } + } + + actual override var printController: PrinterController? = + EpsonPrinterController( + printerData = printer, + epsonPrinter = epsonPrinter, + printerState = connectionState, + ) + + @Suppress("CyclomaticComplexMethod") + private fun String.toModel(): Int = when (this.substringBefore('_')) { + "TM-M10" -> EPOS2_TM_M10 + "TM-M30" -> EPOS2_TM_M30 + "TM-P20" -> EPOS2_TM_P20 + "TM-P60" -> EPOS2_TM_P60 + "TM-P60II" -> EPOS2_TM_P60II + "TM-P80" -> EPOS2_TM_P80 + "TM-T20" -> EPOS2_TM_T20 + "TM-T60" -> EPOS2_TM_T60 + "TM-T70" -> EPOS2_TM_T70 + "TM-T81" -> EPOS2_TM_T81 + "TM-T82" -> EPOS2_TM_T82 + "TM-T83" -> EPOS2_TM_T83 + "TM-T88" -> EPOS2_TM_T88 + "TM-T90" -> EPOS2_TM_T90 + "TM-T90KP" -> EPOS2_TM_T90KP + "TM-U220" -> EPOS2_TM_U220 + "TM-U330" -> EPOS2_TM_U330 + "TM-L90" -> EPOS2_TM_L90 + "TM-H6000" -> EPOS2_TM_H6000 + "TM-T83III" -> EPOS2_TM_T83III + "TM-T100" -> EPOS2_TM_T100 + "TM-M30II" -> EPOS2_TM_M30II + "TS-100" -> EPOS2_TS_100 + "TM-M50" -> EPOS2_TM_M50 + "TM-T88VII" -> EPOS2_TM_T88VII + "TM-L90LFC" -> EPOS2_TM_L90LFC + "TM-L100" -> EPOS2_TM_L100 + "TM-P20II" -> EPOS2_TM_P20II + "TM-P80II" -> EPOS2_TM_P80II + "TM-M30III" -> EPOS2_TM_M30III + // Models not in SDK 2.23.1 — fall back to closest equivalent + "TM-M50II", "TM-M55" -> EPOS2_TM_M50 + "TM-U220II" -> EPOS2_TM_U220 + "EU-M30" -> EPOS2_TM_M30 + "SB-H50" -> EPOS2_TM_T88 + "TM PRINTER", "" -> EPOS2_TM_T88 + else -> { + logger.w { "Unknown printer model '$this', falling back to TM-T88" } + EPOS2_TM_T88 + } + } } diff --git a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.ios.kt index 686a012..b397d7e 100644 --- a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.ios.kt +++ b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.ios.kt @@ -1,60 +1,216 @@ package de.tillhub.printengine.epson +import androidx.compose.ui.graphics.ImageBitmap +import com.epson.epos2.EPOS2_ALIGN_CENTER +import com.epson.epos2.EPOS2_BARCODE_CODE128 +import com.epson.epos2.EPOS2_COLOR_1 +import com.epson.epos2.EPOS2_COMPRESS_AUTO +import com.epson.epos2.EPOS2_CUT_NO_FEED +import com.epson.epos2.EPOS2_FONT_A +import com.epson.epos2.EPOS2_HALFTONE_DITHER +import com.epson.epos2.EPOS2_HRI_BELOW +import com.epson.epos2.EPOS2_LEVEL_M +import com.epson.epos2.EPOS2_MODE_MONO +import com.epson.epos2.EPOS2_PARAM_DEFAULT +import com.epson.epos2.EPOS2_PARAM_UNSPECIFIED +import com.epson.epos2.EPOS2_SUCCESS +import com.epson.epos2.EPOS2_SYMBOL_QRCODE_MODEL_1 +import com.epson.epos2.EPOS2_TRUE +import com.epson.epos2.Epos2Printer import de.tillhub.printengine.PrinterController -import de.tillhub.printengine.data.ImageBitmap +import de.tillhub.printengine.data.ExternalPrinter import de.tillhub.printengine.data.PrinterInfo import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.PrintingFontType import de.tillhub.printengine.data.PrintingIntensity import de.tillhub.printengine.data.RawPrinterData +import de.tillhub.printengine.data.encodeToPngBytes +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import platform.Foundation.NSData +import platform.Foundation.create +import platform.UIKit.UIImage -internal actual class EpsonPrinterController : PrinterController { - actual override fun sendRawData(data: RawPrinterData) { - TODO("Not yet implemented") +@Suppress("TooManyFunctions") +@OptIn(ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) +internal actual class EpsonPrinterController( + private val printerData: ExternalPrinter, + private val epsonPrinter: Epos2Printer, + private val printerState: MutableStateFlow, +) : PrinterController { + + actual override fun sendRawData(data: RawPrinterData) = executeEpsonCommand { + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + checkSdkResult(epsonPrinter.addCommand(data.bytes.toNSData()), "addCommand") } - actual override fun observePrinterState(): Flow { - TODO("Not yet implemented") + actual override fun observePrinterState(): Flow = printerState + + actual override fun setFontSize(fontSize: PrintingFontType) = executeEpsonCommand { + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + checkSdkResult( + epsonPrinter.addTextFont( + when (fontSize) { + PrintingFontType.DEFAULT_FONT_SIZE -> EPOS2_FONT_A + }, + ), + "addTextFont", + ) } - actual override fun setFontSize(fontSize: PrintingFontType) { - TODO("Not yet implemented") + actual override fun printText(text: String) = executeEpsonCommand { + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + checkSdkResult(epsonPrinter.addText("$text\n"), "addText") } - actual override fun printText(text: String) { - TODO("Not yet implemented") + actual override fun printBarcode(barcode: String) = executeEpsonCommand { + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + // "{B" forces Code Set B — same convention as Android + checkSdkResult( + epsonPrinter.addBarcode( + "{B$barcode", + EPOS2_BARCODE_CODE128, + EPOS2_HRI_BELOW, + EPOS2_FONT_A, + BARCODE_MODULE_WIDTH.toLong(), + BARCODE_HEIGHT.toLong(), + ), + "addBarcode", + ) + checkSdkResult(epsonPrinter.addFeedLine(THREE_FEED_LINES.toLong()), "addFeedLine") } - actual override fun printBarcode(barcode: String) { - TODO("Not yet implemented") + actual override fun printQr(qrData: String) = executeEpsonCommand { + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + checkSdkResult( + epsonPrinter.addSymbol( + qrData, + EPOS2_SYMBOL_QRCODE_MODEL_1, + EPOS2_LEVEL_M, + QR_DIMENSION.toLong(), + QR_DIMENSION.toLong(), + EPOS2_PARAM_UNSPECIFIED.toLong(), + ), + "addSymbol", + ) } - actual override fun printQr(qrData: String) { - TODO("Not yet implemented") + actual override fun printImage(image: ImageBitmap) = executeEpsonCommand { + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + val uiImage = image.toUIImage() ?: return@executeEpsonCommand + checkSdkResult( + epsonPrinter.addImage( + uiImage, + IMAGE_START_XY.toLong(), + IMAGE_START_XY.toLong(), + image.width.toLong(), + image.height.toLong(), + EPOS2_COLOR_1, + EPOS2_MODE_MONO, + EPOS2_HALFTONE_DITHER, + IMAGE_BRIGHTNESS, + EPOS2_COMPRESS_AUTO, + ), + "addImage", + ) } - actual override fun printImage(image: ImageBitmap) { - TODO("Not yet implemented") + actual override fun feedPaper() = executeEpsonCommand { + checkSdkResult(epsonPrinter.addFeedLine(ONE_FEED_LINE.toLong()), "addFeedLine") } - actual override fun feedPaper() { - TODO("Not yet implemented") + actual override fun cutPaper() = executeEpsonCommand { + checkSdkResult(epsonPrinter.addCut(EPOS2_CUT_NO_FEED), "addCut") } - actual override fun cutPaper() { - TODO("Not yet implemented") + actual override fun setIntensity(intensity: PrintingIntensity) = Unit + + actual override fun start() { + var connected = false + executeEpsonCommand { + val status = epsonPrinter.getStatus() + if (status?.connection != EPOS2_TRUE) { + checkSdkResult( + epsonPrinter.connect(printerData.getTarget(), EPOS2_PARAM_DEFAULT.toLong()), + "connect", + ) + } + connected = true + checkSdkResult(epsonPrinter.sendData(EPOS2_PARAM_DEFAULT.toLong()), "sendData") + } + epsonPrinter.clearCommandBuffer() + if (connected) { + try { + epsonPrinter.disconnect() + } catch (_: Exception) { + // Disconnect failure is non-critical; connection will be re-established next print + } + } } - actual override fun setIntensity(intensity: PrintingIntensity) { - TODO("Not yet implemented") + actual override suspend fun getPrinterInfo(): PrinterInfo = printerData.info + + /** + * Checks an Epson SDK return code and throws [EpsonSdkException] on failure. + * iOS SDK methods return int codes (0 = success) rather than throwing exceptions, + * so we must check every return value explicitly. + */ + private fun checkSdkResult(result: Int, operation: String) { + if (result != EPOS2_SUCCESS) { + throw EpsonSdkException(result, operation) + } } - actual override fun start() { - TODO("Not yet implemented") + private fun executeEpsonCommand(command: () -> Unit) { + try { + command.invoke() + } catch (e: EpsonSdkException) { + printerState.value = EpsonPrinterErrorState.epsonErrorStatusToState(e.errorCode) + epsonPrinter.clearCommandBuffer() + } catch (e: Exception) { + printerState.value = PrinterState.Error.Epson.InternalError + epsonPrinter.clearCommandBuffer() + } } - actual override suspend fun getPrinterInfo(): PrinterInfo { - TODO("Not yet implemented") + private fun ExternalPrinter.getTarget() = "${connectionType.value}:$connectionAddress" + + @OptIn(ExperimentalForeignApi::class) + private fun ByteArray.toNSData(): NSData { + if (isEmpty()) return NSData() + return usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun ImageBitmap.toUIImage(): UIImage? { + val pngBytes = encodeToPngBytes() ?: return null + val nsData = pngBytes.usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = pngBytes.size.toULong()) + } + return UIImage(data = nsData) + } + + private companion object { + const val QR_DIMENSION = 240 + const val BARCODE_HEIGHT = 100 + const val BARCODE_MODULE_WIDTH = 3 + const val ONE_FEED_LINE = 1 + const val THREE_FEED_LINES = 3 + const val IMAGE_BRIGHTNESS = 1.0 + const val IMAGE_START_XY = 0 } } + +/** + * Exception thrown when an Epson SDK method returns a non-zero error code on iOS. + * On Android, the SDK throws Epos2Exception directly; on iOS, we must check return values. + */ +internal class EpsonSdkException( + val errorCode: Int, + val operation: String, +) : Exception("Epson SDK $operation failed with error code: $errorCode") diff --git a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterDiscovery.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterDiscovery.ios.kt index 08c0553..133bfac 100644 --- a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterDiscovery.ios.kt +++ b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterDiscovery.ios.kt @@ -1,10 +1,102 @@ package de.tillhub.printengine.epson +import com.epson.epos2.Epos2DeviceInfo +import com.epson.epos2.Epos2Discovery +import com.epson.epos2.Epos2DiscoveryDelegateProtocol +import com.epson.epos2.Epos2FilterOption +import de.tillhub.printengine.data.ConnectionType import de.tillhub.printengine.data.DiscoveryState +import de.tillhub.printengine.data.ExternalPrinter +import de.tillhub.printengine.data.PrinterInfo +import de.tillhub.printengine.data.PrinterServiceVersion +import de.tillhub.printengine.data.PrintingFontType +import de.tillhub.printengine.data.PrintingPaperSpec import de.tillhub.printengine.external.PrinterDiscovery +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext +import platform.darwin.NSObject +@OptIn(ExperimentalForeignApi::class) actual class EpsonPrinterDiscovery : PrinterDiscovery { + + private companion object { + const val CHARACTER_COUNT = 42 + const val MANUFACTURER_EPSON = "EPSON" + + // ePOS2.h enum values + const val EPOS2_SUCCESS = 0 + const val EPOS2_PORTTYPE_ALL = 0 + const val EPOS2_TYPE_PRINTER = 1 + const val EPOS2_MODEL_ALL = 0 + } + actual override val observePrinters: Flow - get() = TODO("Not yet implemented") + get() = callbackFlow { + trySend(DiscoveryState.Idle) + + val discoveredPrinters = mutableMapOf() + + val filterOption = Epos2FilterOption().apply { + portType = EPOS2_PORTTYPE_ALL + deviceType = EPOS2_TYPE_PRINTER + deviceModel = EPOS2_MODEL_ALL + } + + // Epson SDK dispatches onDiscovery callbacks sequentially on its internal thread + val discoveryDelegate = object : NSObject(), Epos2DiscoveryDelegateProtocol { + override fun onDiscovery(deviceInfo: Epos2DeviceInfo?) { + val info = deviceInfo ?: return + if (!info.isValid()) return + val printer = createPrinter(info) + discoveredPrinters[printer.connectionAddress] = printer + trySend(DiscoveryState.Discovering(discoveredPrinters.values.toList())) + } + } + + val result = withContext(Dispatchers.Main) { + Epos2Discovery.start(filterOption, discoveryDelegate) + } + if (result != EPOS2_SUCCESS) { + trySend(DiscoveryState.Error("Discovery start failed with code: $result")) + } + + awaitClose { + Epos2Discovery.stop() + // Reference delegate to prevent Kotlin/Native GC from collecting it + // while the Epson SDK still holds a weak reference to it + discoveryDelegate.hashCode() + } + } + + private fun createPrinter(deviceInfo: Epos2DeviceInfo): ExternalPrinter { + val target = deviceInfo.target ?: "" + val dividerIdx = target.indexOf(':') + val protocol = if (dividerIdx >= 0) target.substring(0, dividerIdx) else "" + val address = if (dividerIdx >= 0) target.substring(dividerIdx + 1) else target + + return ExternalPrinter( + info = PrinterInfo( + serialNumber = "n/a", + deviceModel = deviceInfo.deviceName ?: "", + printerVersion = "n/a", + printerPaperSpec = PrintingPaperSpec.External(CHARACTER_COUNT), + printingFontType = PrintingFontType.DEFAULT_FONT_SIZE, + printerHead = "n/a", + printedDistance = 0, + serviceVersion = PrinterServiceVersion.Unknown, + ), + manufacturer = MANUFACTURER_EPSON, + connectionAddress = address, + connectionType = protocol.toConnectionType(), + ) + } + + private fun String.toConnectionType(): ConnectionType = ConnectionType.entries.find { it.value == this } + ?: ConnectionType.LAN + + private fun Epos2DeviceInfo.isValid(): Boolean = deviceType == EPOS2_TYPE_PRINTER && !deviceName.isNullOrEmpty() } diff --git a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterErrorState.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterErrorState.ios.kt new file mode 100644 index 0000000..615fcb3 --- /dev/null +++ b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterErrorState.ios.kt @@ -0,0 +1,81 @@ +package de.tillhub.printengine.epson + +import com.epson.epos2.EPOS2_AUTOCUTTER_ERR +import com.epson.epos2.EPOS2_AUTORECOVER_ERR +import com.epson.epos2.EPOS2_BATTERY_LEVEL_0 +import com.epson.epos2.EPOS2_BATTERY_OVERHEAT +import com.epson.epos2.EPOS2_CODE_PRINTING +import com.epson.epos2.EPOS2_CODE_SUCCESS +import com.epson.epos2.EPOS2_COVER_OPEN +import com.epson.epos2.EPOS2_ERR_CONNECT +import com.epson.epos2.EPOS2_ERR_DISCONNECT +import com.epson.epos2.EPOS2_ERR_MEMORY +import com.epson.epos2.EPOS2_ERR_PARAM +import com.epson.epos2.EPOS2_ERR_TIMEOUT +import com.epson.epos2.EPOS2_FALSE +import com.epson.epos2.EPOS2_HEAD_OVERHEAT +import com.epson.epos2.EPOS2_MECHANICAL_ERR +import com.epson.epos2.EPOS2_MOTOR_OVERHEAT +import com.epson.epos2.EPOS2_NO_ERR +import com.epson.epos2.EPOS2_PAPER_EMPTY +import com.epson.epos2.EPOS2_TRUE +import com.epson.epos2.EPOS2_UNKNOWN +import com.epson.epos2.EPOS2_UNRECOVER_ERR +import com.epson.epos2.EPOS2_WRONG_PAPER +import com.epson.epos2.Epos2PrinterStatusInfo +import de.tillhub.printengine.data.PrinterState +import kotlinx.cinterop.ExperimentalForeignApi + +// Epos2UnrecoverError enum values — not commonized by cinterop across iOS targets +private const val EPOS2_HIGH_VOLTAGE_ERR = 0 +private const val EPOS2_LOW_VOLTAGE_ERR = 1 + +@OptIn(ExperimentalForeignApi::class) +internal object EpsonPrinterErrorState { + + fun epsonErrorStatusToState(errorCode: Int): PrinterState = when (errorCode) { + EPOS2_ERR_PARAM -> PrinterState.Error.Epson.DataError + EPOS2_ERR_CONNECT -> PrinterState.Error.NotAvailable + EPOS2_ERR_TIMEOUT -> PrinterState.Error.ConnectionLost + EPOS2_ERR_DISCONNECT -> PrinterState.Error.ConnectionLost + EPOS2_ERR_MEMORY -> PrinterState.Error.Epson.MemoryError + else -> PrinterState.Error.Epson.InternalError + } + + fun epsonStatusToState(code: Int, status: Epos2PrinterStatusInfo?): PrinterState = when (code) { + EPOS2_CODE_SUCCESS -> PrinterState.Connected + EPOS2_CODE_PRINTING -> PrinterState.Busy + else -> if (status != null) evaluateStatusInfo(status) else PrinterState.Error.Epson.InternalError + } + + private fun evaluateStatusInfo(status: Epos2PrinterStatusInfo): PrinterState = when { + status.online == EPOS2_FALSE -> PrinterState.Error.ConnectionLost + status.paper == EPOS2_PAPER_EMPTY -> PrinterState.Error.OutOfPaper + status.coverOpen == EPOS2_TRUE -> PrinterState.Error.CoverNotClosed + status.errorStatus == EPOS2_NO_ERR -> PrinterState.Connected + status.errorStatus == EPOS2_MECHANICAL_ERR || + status.errorStatus == EPOS2_UNKNOWN -> PrinterState.Error.Malfunctions + status.errorStatus == EPOS2_AUTOCUTTER_ERR -> PrinterState.Error.PaperCutterAbnormal + status.errorStatus == EPOS2_UNRECOVER_ERR -> handleUnrecoverableError(status) + status.errorStatus == EPOS2_AUTORECOVER_ERR -> handleAutoRecoverableError(status) + status.batteryLevel == EPOS2_BATTERY_LEVEL_0 -> PrinterState.Error.VoltageTooLow + else -> PrinterState.Connected + } + + private fun handleUnrecoverableError(status: Epos2PrinterStatusInfo): PrinterState = when (status.unrecoverError) { + EPOS2_LOW_VOLTAGE_ERR, + EPOS2_HIGH_VOLTAGE_ERR, + -> PrinterState.Error.VoltageTooLow + else -> PrinterState.Error.Malfunctions + } + + private fun handleAutoRecoverableError(status: Epos2PrinterStatusInfo): PrinterState = when (status.autoRecoverError) { + EPOS2_HEAD_OVERHEAT, + EPOS2_MOTOR_OVERHEAT, + EPOS2_BATTERY_OVERHEAT, + -> PrinterState.Error.Overheated + EPOS2_WRONG_PAPER -> PrinterState.Error.PaperAbnormal + EPOS2_COVER_OPEN -> PrinterState.Error.CoverNotClosed + else -> PrinterState.Error.Malfunctions + } +} diff --git a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonServiceProvider.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonServiceProvider.ios.kt index e46081e..fecccc5 100644 --- a/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonServiceProvider.ios.kt +++ b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonServiceProvider.ios.kt @@ -1,5 +1,18 @@ package de.tillhub.printengine.epson +import de.tillhub.printengine.PrintService import de.tillhub.printengine.PrinterServiceProvider +import de.tillhub.printengine.barcode.BarcodeEncoder +import de.tillhub.printengine.data.ExternalPrinter -actual object EpsonServiceProvider : PrinterServiceProvider +actual object EpsonServiceProvider : PrinterServiceProvider { + override fun build( + printer: ExternalPrinter?, + barcode: BarcodeEncoder?, + ): PrintService { + requireNotNull(printer) { + "EpsonServiceProvider requires an ExternalPrinter configuration" + } + return EpsonPrintService(printer) + } +} diff --git a/print-plugins/pax/build.gradle.kts b/print-plugins/pax/build.gradle.kts index 7be22e6..e09a155 100644 --- a/print-plugins/pax/build.gradle.kts +++ b/print-plugins/pax/build.gradle.kts @@ -4,6 +4,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) alias(libs.plugins.maven.publish) } @@ -58,6 +60,7 @@ dependencies { // Core Dependencies implementation(libs.androidx.core) implementation(libs.kotlin.coroutines) + implementation(compose.ui) coreLibraryDesugaring(libs.android.desugarJdkLibs) // Pax Dependencies diff --git a/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt b/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt index 2aadcf6..a42e14e 100644 --- a/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt +++ b/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt @@ -1,6 +1,8 @@ package de.tillhub.printengine.pax import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import de.tillhub.printengine.barcode.BarcodeEncoder import de.tillhub.printengine.barcode.BarcodeType import de.tillhub.printengine.data.PrinterInfo @@ -27,14 +29,16 @@ import kotlinx.coroutines.flow.first internal class PaxPrinterControllerTest : DescribeSpec({ - lateinit var bitmap: Bitmap + lateinit var androidBitmap: Bitmap + lateinit var bitmap: ImageBitmap lateinit var printerState: MutableStateFlow lateinit var printService: DirectPrintService lateinit var barcodeEncoder: BarcodeEncoder lateinit var target: PaxPrinterController beforeSpec { - bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + androidBitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + bitmap = androidBitmap.asImageBitmap() } beforeTest { @@ -58,7 +62,7 @@ internal class PaxPrinterControllerTest : } afterSpec { - bitmap.recycle() + androidBitmap.recycle() } it("getPrinterInfo") { diff --git a/print-plugins/star/build.gradle.kts b/print-plugins/star/build.gradle.kts index 24e3607..a1a989e 100644 --- a/print-plugins/star/build.gradle.kts +++ b/print-plugins/star/build.gradle.kts @@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) alias(libs.plugins.maven.publish) } @@ -45,6 +47,7 @@ kotlin { implementation(project(":print-engine")) implementation(libs.kotlin.coroutines) + implementation(compose.ui) // Utils implementation(libs.kermit) diff --git a/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt b/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt index 097afa1..c0d38c7 100644 --- a/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt +++ b/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt @@ -1,6 +1,7 @@ package de.tillhub.printengine.star -import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap import com.starmicronics.stario10.StarIO10Exception import com.starmicronics.stario10.StarPrinter import com.starmicronics.stario10.starxpandcommand.DocumentBuilder @@ -78,9 +79,9 @@ internal actual class StarPrinterController( ).styleAlignment(Alignment.Center) } - actual override fun printImage(image: Bitmap) { + actual override fun printImage(image: ImageBitmap) { printerBuilder - .actionPrintImage(ImageParameter(image, IMAGE_WIDTH)) + .actionPrintImage(ImageParameter(image.asAndroidBitmap(), IMAGE_WIDTH)) .styleAlignment(Alignment.Center) } diff --git a/print-plugins/star/src/androidUnitTest/kotlin/de/tillhub/printengine/star/StarPrinterControllerTest.kt b/print-plugins/star/src/androidUnitTest/kotlin/de/tillhub/printengine/star/StarPrinterControllerTest.kt index 279dc6b..90b8c18 100644 --- a/print-plugins/star/src/androidUnitTest/kotlin/de/tillhub/printengine/star/StarPrinterControllerTest.kt +++ b/print-plugins/star/src/androidUnitTest/kotlin/de/tillhub/printengine/star/StarPrinterControllerTest.kt @@ -1,6 +1,7 @@ package de.tillhub.printengine.star import android.graphics.Bitmap +import androidx.compose.ui.graphics.asImageBitmap import com.starmicronics.stario10.StarIO10ErrorCode import com.starmicronics.stario10.StarIO10Exception import com.starmicronics.stario10.StarPrinter @@ -14,6 +15,7 @@ import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.PrintingFontType import de.tillhub.printengine.data.RawPrinterData import io.kotest.core.spec.style.FunSpec +import io.kotest.extensions.robolectric.RobolectricTest import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.coVerifyOrder @@ -28,6 +30,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @OptIn(ExperimentalCoroutinesApi::class) +@RobolectricTest class StarPrinterControllerTest : FunSpec({ lateinit var starPrinter: StarPrinter @@ -130,9 +133,10 @@ class StarPrinterControllerTest : } test("printImage adds image with correct parameters") { - val bitmap = mockk() + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val imageBitmap = bitmap.asImageBitmap() - controller.printImage(bitmap) + controller.printImage(imageBitmap) verifyOrder { printerBuilder.actionPrintImage(any()) diff --git a/print-plugins/star/src/commonMain/kotlin/de/tillhub/printengine/star/StarPrinterController.kt b/print-plugins/star/src/commonMain/kotlin/de/tillhub/printengine/star/StarPrinterController.kt index c55f1c3..fd2ac1d 100644 --- a/print-plugins/star/src/commonMain/kotlin/de/tillhub/printengine/star/StarPrinterController.kt +++ b/print-plugins/star/src/commonMain/kotlin/de/tillhub/printengine/star/StarPrinterController.kt @@ -1,7 +1,7 @@ package de.tillhub.printengine.star +import androidx.compose.ui.graphics.ImageBitmap import de.tillhub.printengine.PrinterController -import de.tillhub.printengine.data.ImageBitmap import de.tillhub.printengine.data.PrinterInfo import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.PrintingFontType diff --git a/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarPrinterController.ios.kt b/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarPrinterController.ios.kt index 1521d88..0f9a4b5 100644 --- a/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarPrinterController.ios.kt +++ b/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarPrinterController.ios.kt @@ -1,7 +1,7 @@ package de.tillhub.printengine.star +import androidx.compose.ui.graphics.ImageBitmap import de.tillhub.printengine.PrinterController -import de.tillhub.printengine.data.ImageBitmap import de.tillhub.printengine.data.PrinterInfo import de.tillhub.printengine.data.PrinterState import de.tillhub.printengine.data.PrintingFontType diff --git a/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarServiceProvider.ios.kt b/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarServiceProvider.ios.kt index e5cb296..41ab468 100644 --- a/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarServiceProvider.ios.kt +++ b/print-plugins/star/src/iosMain/kotlin/de/tillhub/printengine/star/StarServiceProvider.ios.kt @@ -1,5 +1,13 @@ package de.tillhub.printengine.star +import de.tillhub.printengine.PrintService import de.tillhub.printengine.PrinterServiceProvider +import de.tillhub.printengine.barcode.BarcodeEncoder +import de.tillhub.printengine.data.ExternalPrinter -actual object StarServiceProvider : PrinterServiceProvider +actual object StarServiceProvider : PrinterServiceProvider { + override fun build( + printer: ExternalPrinter?, + barcode: BarcodeEncoder?, + ): PrintService = StarPrintService() +} diff --git a/print-plugins/sunmi/build.gradle.kts b/print-plugins/sunmi/build.gradle.kts index c528969..f664105 100644 --- a/print-plugins/sunmi/build.gradle.kts +++ b/print-plugins/sunmi/build.gradle.kts @@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) } android { @@ -56,6 +58,7 @@ dependencies { // Core Dependencies implementation(libs.androidx.core) implementation(libs.kotlin.coroutines) + implementation(compose.ui) coreLibraryDesugaring(libs.android.desugarJdkLibs) // Sunmi Dependencies diff --git a/print-plugins/sunmi/src/main/java/de/tillhub/printengine/sunmi/SunmiPrinterController.kt b/print-plugins/sunmi/src/main/java/de/tillhub/printengine/sunmi/SunmiPrinterController.kt index 6e15b98..de5dfb7 100644 --- a/print-plugins/sunmi/src/main/java/de/tillhub/printengine/sunmi/SunmiPrinterController.kt +++ b/print-plugins/sunmi/src/main/java/de/tillhub/printengine/sunmi/SunmiPrinterController.kt @@ -1,7 +1,8 @@ package de.tillhub.printengine.sunmi -import android.graphics.Bitmap import android.os.RemoteException +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap import co.touchlab.kermit.Logger import com.sunmi.peripheral.printer.InnerResultCallback import com.sunmi.peripheral.printer.SunmiPrinterService @@ -94,9 +95,9 @@ internal class SunmiPrinterController( printerService.lineWrap(1, printListener) } - override fun printImage(image: Bitmap) { + override fun printImage(image: ImageBitmap) { printerService.setAlignment(Alignment.CENTER.value, printListener) - printerService.printBitmapCustom(image, ImagePrintingMethod.GRAYSCALE.value, printListener) + printerService.printBitmapCustom(image.asAndroidBitmap(), ImagePrintingMethod.GRAYSCALE.value, printListener) printerService.setAlignment(Alignment.LEFT.value, printListener) printerService.lineWrap(1, printListener) } diff --git a/print-plugins/sunmi/src/test/java/de/tillhub/printengine/sunmi/SunmiPrinterControllerTest.kt b/print-plugins/sunmi/src/test/java/de/tillhub/printengine/sunmi/SunmiPrinterControllerTest.kt index 01ea099..94e23b1 100644 --- a/print-plugins/sunmi/src/test/java/de/tillhub/printengine/sunmi/SunmiPrinterControllerTest.kt +++ b/print-plugins/sunmi/src/test/java/de/tillhub/printengine/sunmi/SunmiPrinterControllerTest.kt @@ -2,6 +2,7 @@ package de.tillhub.printengine.sunmi import android.graphics.Bitmap import android.os.RemoteException +import androidx.compose.ui.graphics.asImageBitmap import com.sunmi.peripheral.printer.SunmiPrinterService import de.tillhub.printengine.PrinterController import de.tillhub.printengine.data.PrinterServiceVersion @@ -103,11 +104,11 @@ internal class SunmiPrinterControllerTest : test("printImage") { val bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) - printerController.printImage(bitmap) + printerController.printImage(bitmap.asImageBitmap()) verify(ordering = Ordering.ORDERED) { printerService.setAlignment(1, any()) - printerService.printBitmapCustom(bitmap, 2, any()) + printerService.printBitmapCustom(any(), 2, any()) printerService.setAlignment(0, any()) printerService.lineWrap(1, any()) } diff --git a/print-plugins/verifone/build.gradle.kts b/print-plugins/verifone/build.gradle.kts index a088b37..4d44f80 100644 --- a/print-plugins/verifone/build.gradle.kts +++ b/print-plugins/verifone/build.gradle.kts @@ -4,6 +4,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) alias(libs.plugins.maven.publish) } @@ -59,6 +61,7 @@ dependencies { // Core Dependencies implementation(libs.androidx.core) implementation(libs.kotlin.coroutines) + implementation(compose.ui) coreLibraryDesugaring(libs.android.desugarJdkLibs) // Utils diff --git a/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt b/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt index 460059f..c9906f4 100644 --- a/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt +++ b/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt @@ -1,6 +1,8 @@ package de.tillhub.printengine.verifone import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import com.verifone.peripherals.DirectPrintManager import com.verifone.peripherals.Printer import de.tillhub.printengine.PrinterController @@ -29,14 +31,16 @@ import kotlinx.coroutines.flow.first internal class VerifonePrintControllerTest : DescribeSpec({ - lateinit var bitmap: Bitmap + lateinit var androidBitmap: Bitmap + lateinit var bitmap: ImageBitmap lateinit var printManager: DirectPrintManager lateinit var printerState: MutableStateFlow lateinit var barcodeEncoder: BarcodeEncoder lateinit var printerController: PrinterController beforeSpec { - bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + androidBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + bitmap = androidBitmap.asImageBitmap() } beforeTest { @@ -60,7 +64,7 @@ internal class VerifonePrintControllerTest : } afterSpec { - bitmap.recycle() + androidBitmap.recycle() } it("observePrinterState") { diff --git a/sample/src/commonMain/kotlin/de/tillhub/printengine/sample/ui/PrinterLayout.kt b/sample/src/commonMain/kotlin/de/tillhub/printengine/sample/ui/PrinterLayout.kt index 506f1f6..924e16b 100644 --- a/sample/src/commonMain/kotlin/de/tillhub/printengine/sample/ui/PrinterLayout.kt +++ b/sample/src/commonMain/kotlin/de/tillhub/printengine/sample/ui/PrinterLayout.kt @@ -30,6 +30,7 @@ fun PrinterLayout( printState: PrinterState, printers: List, onPrinterSelected: (ExternalPrinter?) -> Unit, + discoveryStatus: String? = null, ) { Column( modifier = Modifier.fillMaxSize(), @@ -41,6 +42,14 @@ fun PrinterLayout( textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge, ) + if (discoveryStatus != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = discoveryStatus, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + ) + } Spacer(modifier = Modifier.height(36.dp)) Button(onClick = { onPrinterSelected(null) }) { Text(text = "Print sample job") diff --git a/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt new file mode 100644 index 0000000..890ab7c --- /dev/null +++ b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt @@ -0,0 +1,154 @@ +package de.tillhub.printengine.sample + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.Modifier +import androidx.compose.ui.window.ComposeUIViewController +import de.tillhub.printengine.PrintEngine +import de.tillhub.printengine.data.DiscoveryState +import de.tillhub.printengine.data.ExternalPrinter +import de.tillhub.printengine.data.PrintCommand +import de.tillhub.printengine.data.PrintJob +import de.tillhub.printengine.data.PrinterState +import de.tillhub.printengine.data.PrintingIntensity +import de.tillhub.printengine.epson.EpsonPrinterDiscovery +import de.tillhub.printengine.epson.EpsonServiceProvider +import de.tillhub.printengine.sample.ui.PrinterLayout +import de.tillhub.printengine.sample.ui.theme.TillhubPrintEngineTheme +import kotlinx.coroutines.launch +import platform.UIKit.UIViewController + +@Suppress("ktlint:standard:function-naming") +fun MainViewController(): UIViewController = ComposeUIViewController { + val printEngine = remember { + PrintEngine.getInstance().also { + it.printer.settings.printingIntensity = PrintingIntensity.DEFAULT + } + } + + val printers = remember { mutableStateListOf() } + var initialized by remember { mutableStateOf(false) } + var discoveryStatus by remember { mutableStateOf("Searching for printers…") } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + printEngine.discoverExternalPrinters( + EpsonPrinterDiscovery(), + ).collect { state -> + when (state) { + is DiscoveryState.Idle -> discoveryStatus = "Searching for printers…" + is DiscoveryState.Discovering -> { + printers.clear() + printers.addAll(state.printers) + discoveryStatus = "Found ${state.printers.size} printer(s)" + } + is DiscoveryState.Finished -> { + printers.clear() + printers.addAll(state.printers) + discoveryStatus = "Discovery done: ${state.printers.size} printer(s)" + } + is DiscoveryState.Error -> { + discoveryStatus = "Discovery error: ${state.message}" + } + } + } + } + + val printState by printEngine.printer.printerState + .collectAsState(PrinterState.CheckingForPrinter) + + TillhubPrintEngineTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PrinterLayout( + printState = printState, + discoveryStatus = discoveryStatus, + printers = printers, + onPrinterSelected = { selectedPrinter -> + scope.launch { + if (!initialized && selectedPrinter != null) { + try { + printEngine.initPrinter { + EpsonServiceProvider.build(printer = selectedPrinter) + } + initialized = true + } catch (_: Exception) { + discoveryStatus = "Failed to initialize printer" + return@launch + } + } + if (initialized) { + printEngine.printer.startPrintJob(PRINT_JOB) + } + } + }, + ) + } + } +} + +private val PRINT_JOB = PrintJob( + listOf( + PrintCommand.Text( + " receipt-header \n" + + "------------------------------------------\n" + + "Receipt: 1\n" + + "Salesperson: staff name\n" + + "Date and Time: 20.5.2020 20:00\n" + + "Branch: 1\n" + + "Register: 123\n" + + "------------------------------------------\n" + + "Customer number: 654321\n" + + "------------------------------------------\n" + + " receipt-note \n" + + "------------------------------------------\n" + + "0011 \n" + + "multiline long product item name for \n" + + "receipt att-desc \n" + + "- addon_name \n" + + " 10.00 € 1 10.00 €\n" + + " note \n" + + "voucher-code \n" + + " \n" + + "------------------------------------------\n" + + "Tips 3.00 €\n" + + " \n" + + "******************************************\n" + + "Total (gross): 10.00 €\n" + + "******************************************\n" + + "Given: \n" + + "Cash 10.00 €\n" + + "- including tip: 3.00 €\n" + + "------------------------------------------\n" + + "Change: 10.00 €\n" + + "------------------------------------------\n" + + "Vat \n" + + " 19 % 1.00 €\n" + + "------------------------------------------\n" + + "Net: 10.00 €\n" + + "------------------------------------------\n" + + " receipt-footer \n", + ), + PrintCommand.Text("This is another line"), + PrintCommand.Text("-------"), + PrintCommand.Text("Barcode working:"), + PrintCommand.Barcode("RTC6093739"), + PrintCommand.Text("QR code:"), + PrintCommand.QrCode("123ABC"), + PrintCommand.Text("40 char line:"), + PrintCommand.Text("1234567890123456789012345678901234567890"), + PrintCommand.FeedPaper, + PrintCommand.CutPaper, + ), +)