From 7d299f73106cc24c242590b589b6960c348e6cd2 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:52:47 +0100 Subject: [PATCH 01/16] [UNTIL-19417] Migrate ImageBitmap to Compose Multiplatform (v3.0.5) Replace custom expect/actual ImageBitmap with androidx.compose.ui.graphics.ImageBitmap from Compose Multiplatform as the unified cross-platform image abstraction. - Use Compose ImageBitmap directly across all modules (no typealias indirection) - Implement iOS encodeToBase64() via Skia PNG encoding - Implement iOS BarcodeEncoder using CoreImage (CIQRCodeGenerator, CICode128BarcodeGenerator) - Convert Android plugin controllers (Star, Epson, Sunmi) to use asAndroidBitmap() at SDK boundary - Android BarcodeEncoder returns Compose ImageBitmap via asImageBitmap() - Add Compose plugins to print-engine, star, and epson modules - Update all test files for new ImageBitmap type conversions Co-Authored-By: Claude Opus 4.6 (1M context) --- gradle/libs.versions.toml | 2 +- print-engine/build.gradle.kts | 5 + .../barcode/BarcodeEncoderImpl.android.kt | 5 +- .../printengine/data/ImageBitmap.android.kt | 6 +- .../printengine/PrinterImplWImageTest.kt | 17 +-- .../barcode/BarcodeEncoderImplTest.kt | 9 +- .../printengine/html/HtmlUtilsImageTest.kt | 3 +- .../tillhub/printengine/PrinterController.kt | 2 +- .../printengine/barcode/BarcodeEncoder.kt | 2 +- .../printengine/barcode/BarcodeEncoderImpl.kt | 2 +- .../{ImageBitmap.kt => ImageBitmapExt.kt} | 2 +- .../tillhub/printengine/data/PrintCommand.kt | 2 +- .../printengine/html/HtmlPrinterController.kt | 2 +- .../de/tillhub/printengine/html/HtmlUtils.kt | 2 +- .../barcode/BarcodeEncoderImpl.ios.kt | 110 +++++++++++++++++- .../printengine/data/ImageBitmap.ios.kt | 38 +++++- print-plugins/epson/build.gradle.kts | 3 + .../epson/EpsonPrinterController.android.kt | 12 +- .../epson/EpsonPrinterControllerTest.kt | 14 +-- .../epson/EpsonPrinterController.kt | 2 +- .../epson/EpsonPrinterController.ios.kt | 2 +- .../pax/PaxPrinterControllerTest.kt | 10 +- print-plugins/star/build.gradle.kts | 3 + .../star/StarPrinterController.android.kt | 7 +- .../star/StarPrinterControllerTest.kt | 8 +- .../printengine/star/StarPrinterController.kt | 2 +- .../star/StarPrinterController.ios.kt | 2 +- .../sunmi/SunmiPrinterController.kt | 7 +- .../sunmi/SunmiPrinterControllerTest.kt | 5 +- .../verifone/VerifonePrintControllerTest.kt | 10 +- 30 files changed, 234 insertions(+), 62 deletions(-) rename print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/{ImageBitmap.kt => ImageBitmapExt.kt} (64%) 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/print-engine/build.gradle.kts b/print-engine/build.gradle.kts index 416d025..4163614 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 + api(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..3c4af1a 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,7 @@ package de.tillhub.printengine import android.graphics.Bitmap +import androidx.compose.ui.graphics.asImageBitmap import de.tillhub.printengine.analytics.PrintAnalytics import de.tillhub.printengine.data.PrintCommand import de.tillhub.printengine.data.PrintJob @@ -91,7 +92,7 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap), + PrintCommand.Image(bitmap.asImageBitmap()), PrintCommand.Text("receipt_to_print"), ), ), @@ -100,7 +101,7 @@ internal class PrinterImplWImageTest : coVerify(ordering = Ordering.ORDERED) { controller.setIntensity(PrintingIntensity.DEFAULT) controller.setFontSize(PrintingFontType.DEFAULT_FONT_SIZE) - controller.printImage(bitmap) + controller.printImage(any()) controller.printText("receipt_to_print") controller.start() analytics.logPrintReceipt( @@ -114,11 +115,11 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap), + PrintCommand.Image(bitmap.asImageBitmap()), PrintCommand.Text("raw_receipt_text"), PrintCommand.QrCode("signature_qr_code"), PrintCommand.Barcode("barcode"), - PrintCommand.Image(footerBitmap), + PrintCommand.Image(footerBitmap.asImageBitmap()), PrintCommand.FeedPaper, ), ), @@ -127,11 +128,11 @@ internal class PrinterImplWImageTest : coVerify(ordering = Ordering.ORDERED) { controller.setIntensity(PrintingIntensity.DEFAULT) controller.setFontSize(PrintingFontType.DEFAULT_FONT_SIZE) - controller.printImage(bitmap) + controller.printImage(any()) controller.printText("raw_receipt_text") controller.printQr("signature_qr_code") controller.printBarcode("barcode") - controller.printImage(footerBitmap) + controller.printImage(any()) controller.feedPaper() controller.start() analytics.logPrintReceipt( @@ -155,11 +156,11 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap), + PrintCommand.Image(bitmap.asImageBitmap()), PrintCommand.Text("raw_receipt_text"), PrintCommand.QrCode("signature_qr_code"), PrintCommand.Barcode("barcode"), - PrintCommand.Image(footerBitmap), + PrintCommand.Image(footerBitmap.asImageBitmap()), 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..9d35634 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 { 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/barcode/BarcodeEncoderImpl.ios.kt b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.ios.kt index c80d1f1..431b551 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,120 @@ 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) + ciBasedImage.drawInRect(CGRectMake(0.0, 0.0, imgWidth.toDouble(), imgHeight.toDouble())) + val renderedImage = UIGraphicsGetImageFromCurrentImageContext() + 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 { + SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() + } catch (e: Exception) { + Logger.e("Failed to decode barcode image", e) + null + } + } + + /** + * Creates a CIFilter via ObjC runtime +[CIFilter filterWithName:]. + * K/N bindings don't expose this factory method, so we call it + * via performSelector on the CIFilter class object. + */ + @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..270cbc4 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,41 @@ package de.tillhub.printengine.data -actual class ImageBitmap +import androidx.compose.ui.graphics.ImageBitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.ImageInfo +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import org.jetbrains.skia.Bitmap as SkiaBitmap +import org.jetbrains.skia.Image as SkiaImage +@OptIn(ExperimentalEncodingApi::class) actual fun ImageBitmap.encodeToBase64(): String { - TODO("Not yet implemented") + val width = this.width + val height = this.height + val buffer = IntArray(width * height) + this.readPixels(buffer) + + // readPixels returns ARGB_8888 int format. On little-endian systems, + // storing these ints as bytes produces BGRA byte order, which matches + // Skia's N32 native format (BGRA_8888 on little-endian). + val bytes = ByteArray(buffer.size * 4) + for (i in buffer.indices) { + val pixel = buffer[i] + bytes[i * 4 + 0] = (pixel and 0xFF).toByte() + bytes[i * 4 + 1] = (pixel shr 8 and 0xFF).toByte() + bytes[i * 4 + 2] = (pixel shr 16 and 0xFF).toByte() + bytes[i * 4 + 3] = (pixel shr 24 and 0xFF).toByte() + } + + val imageInfo = ImageInfo.makeN32Premul(width, height) + val skiaBitmap = SkiaBitmap().apply { + allocPixels(imageInfo) + installPixels(imageInfo, bytes, width * 4) + } + + val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap) + val pngData = skiaImage.encodeToData(EncodedImageFormat.PNG) + ?: error("Failed to encode image to PNG") + + return Base64.encode(pngData.bytes) } diff --git a/print-plugins/epson/build.gradle.kts b/print-plugins/epson/build.gradle.kts index 6c4dffb..5c9f3a5 100644 --- a/print-plugins/epson/build.gradle.kts +++ b/print-plugins/epson/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) } @@ -48,6 +50,7 @@ kotlin { implementation(project(":print-engine")) implementation(libs.kotlin.coroutines) + implementation(compose.ui) // Utils implementation(libs.kermit) 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/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/EpsonPrinterController.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterController.ios.kt index 686a012..12e7584 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,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/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/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/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") { From 8cebb40eee68615d23378a6bd6b7f3a7e6c1f810 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:02:33 +0100 Subject: [PATCH 02/16] [UNTIL-19417] Add iOS sample app with Compose Multiplatform support Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 7 + README.md | 6 +- iosApp/iosApp.xcodeproj/project.pbxproj | 289 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + iosApp/iosApp/ContentView.swift | 18 ++ iosApp/iosApp/Info.plist | 8 + iosApp/iosApp/iOSApp.swift | 10 + .../printengine/PrinterImplWImageTest.kt | 22 +- .../tillhub/printengine/data/PrintCommand.kt | 2 +- .../barcode/BarcodeEncoderImpl.ios.kt | 9 +- .../printengine/data/ImageBitmap.ios.kt | 21 +- print-plugins/epson/build.gradle.kts | 1 - print-plugins/star/build.gradle.kts | 1 - .../printengine/sample/MainViewController.kt | 41 +++ 14 files changed, 415 insertions(+), 27 deletions(-) create mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 iosApp/iosApp/ContentView.swift create mode 100644 iosApp/iosApp/Info.plist create mode 100644 iosApp/iosApp/iOSApp.swift create mode 100644 sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt diff --git a/.gitignore b/.gitignore index 2294e4c..f89fa2d 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,10 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +# Claude Code local settings +.claude/settings.local.json + +# Xcode user-specific data +xcuserdata/ +*.xcuserstate diff --git a/README.md b/README.md index b7847f3..9141e13 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,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 +192,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, diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8f09a88 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,289 @@ +// !$*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 "iosApp" */; + 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 = "cd \"$SRCROOT/..\"\n./gradlew :sample:embedAndSignAppleFrameworkForXcode\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; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + ); + 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; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + ); + 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 "iosApp" */ = { + 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/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iosApp/iosApp.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..4f56190 --- /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(.keyboard) + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..11845e1 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,8 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + + 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/src/androidUnitTest/kotlin/de/tillhub/printengine/PrinterImplWImageTest.kt b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/PrinterImplWImageTest.kt index 3c4af1a..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,7 @@ 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 @@ -30,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 @@ -38,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 { @@ -81,6 +86,7 @@ internal class PrinterImplWImageTest : afterSpec { bitmap.recycle() + footerBitmap.recycle() } describe("printer enabled") { @@ -92,7 +98,7 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap.asImageBitmap()), + PrintCommand.Image(imageBitmap), PrintCommand.Text("receipt_to_print"), ), ), @@ -101,7 +107,7 @@ internal class PrinterImplWImageTest : coVerify(ordering = Ordering.ORDERED) { controller.setIntensity(PrintingIntensity.DEFAULT) controller.setFontSize(PrintingFontType.DEFAULT_FONT_SIZE) - controller.printImage(any()) + controller.printImage(imageBitmap) controller.printText("receipt_to_print") controller.start() analytics.logPrintReceipt( @@ -115,11 +121,11 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap.asImageBitmap()), + PrintCommand.Image(imageBitmap), PrintCommand.Text("raw_receipt_text"), PrintCommand.QrCode("signature_qr_code"), PrintCommand.Barcode("barcode"), - PrintCommand.Image(footerBitmap.asImageBitmap()), + PrintCommand.Image(footerImageBitmap), PrintCommand.FeedPaper, ), ), @@ -128,11 +134,11 @@ internal class PrinterImplWImageTest : coVerify(ordering = Ordering.ORDERED) { controller.setIntensity(PrintingIntensity.DEFAULT) controller.setFontSize(PrintingFontType.DEFAULT_FONT_SIZE) - controller.printImage(any()) + controller.printImage(imageBitmap) controller.printText("raw_receipt_text") controller.printQr("signature_qr_code") controller.printBarcode("barcode") - controller.printImage(any()) + controller.printImage(footerImageBitmap) controller.feedPaper() controller.start() analytics.logPrintReceipt( @@ -156,11 +162,11 @@ internal class PrinterImplWImageTest : printer.startPrintJob( PrintJob( listOf( - PrintCommand.Image(bitmap.asImageBitmap()), + PrintCommand.Image(imageBitmap), PrintCommand.Text("raw_receipt_text"), PrintCommand.QrCode("signature_qr_code"), PrintCommand.Barcode("barcode"), - PrintCommand.Image(footerBitmap.asImageBitmap()), + PrintCommand.Image(footerImageBitmap), PrintCommand.FeedPaper, ), ), 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 9d35634..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 @@ -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/iosMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.ios.kt b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/barcode/BarcodeEncoderImpl.ios.kt index 431b551..3f19aa2 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 @@ -77,9 +77,12 @@ internal actual class BarcodeEncoderImpl : BarcodeEncoder { val targetSize = CGSizeMake(imgWidth.toDouble(), imgHeight.toDouble()) UIGraphicsBeginImageContextWithOptions(targetSize, true, 1.0) - ciBasedImage.drawInRect(CGRectMake(0.0, 0.0, imgWidth.toDouble(), imgHeight.toDouble())) - val renderedImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() + 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") 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 270cbc4..71d2c8b 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,30 +1,31 @@ package de.tillhub.printengine.data 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 -@OptIn(ExperimentalEncodingApi::class) +@OptIn(ExperimentalEncodingApi::class, ExperimentalForeignApi::class) actual fun ImageBitmap.encodeToBase64(): String { val width = this.width val height = this.height val buffer = IntArray(width * height) this.readPixels(buffer) - // readPixels returns ARGB_8888 int format. On little-endian systems, - // storing these ints as bytes produces BGRA byte order, which matches - // Skia's N32 native format (BGRA_8888 on little-endian). + // 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) - for (i in buffer.indices) { - val pixel = buffer[i] - bytes[i * 4 + 0] = (pixel and 0xFF).toByte() - bytes[i * 4 + 1] = (pixel shr 8 and 0xFF).toByte() - bytes[i * 4 + 2] = (pixel shr 16 and 0xFF).toByte() - bytes[i * 4 + 3] = (pixel shr 24 and 0xFF).toByte() + buffer.usePinned { src -> + bytes.usePinned { dst -> + memcpy(dst.addressOf(0), src.addressOf(0), bytes.size.toULong()) + } } val imageInfo = ImageInfo.makeN32Premul(width, height) diff --git a/print-plugins/epson/build.gradle.kts b/print-plugins/epson/build.gradle.kts index 5c9f3a5..ba12d4b 100644 --- a/print-plugins/epson/build.gradle.kts +++ b/print-plugins/epson/build.gradle.kts @@ -50,7 +50,6 @@ kotlin { implementation(project(":print-engine")) implementation(libs.kotlin.coroutines) - implementation(compose.ui) // Utils implementation(libs.kermit) diff --git a/print-plugins/star/build.gradle.kts b/print-plugins/star/build.gradle.kts index a1a989e..da07327 100644 --- a/print-plugins/star/build.gradle.kts +++ b/print-plugins/star/build.gradle.kts @@ -47,7 +47,6 @@ kotlin { implementation(project(":print-engine")) implementation(libs.kotlin.coroutines) - implementation(compose.ui) // Utils implementation(libs.kermit) 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..88c6f77 --- /dev/null +++ b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt @@ -0,0 +1,41 @@ +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.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.ComposeUIViewController +import de.tillhub.printengine.PrintEngine +import de.tillhub.printengine.data.ExternalPrinter +import de.tillhub.printengine.data.PrinterState +import de.tillhub.printengine.data.PrintingIntensity +import de.tillhub.printengine.sample.ui.PrinterLayout +import de.tillhub.printengine.sample.ui.theme.TillhubPrintEngineTheme +import platform.UIKit.UIViewController + +@Suppress("ktlint:standard:function-naming") +fun MainViewController(): UIViewController = ComposeUIViewController { + remember { + PrintEngine.getInstance().also { + it.printer.settings.printingIntensity = PrintingIntensity.DEFAULT + } + } + val printers = remember { mutableStateListOf() } + + TillhubPrintEngineTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PrinterLayout( + printState = PrinterState.CheckingForPrinter, + printers = printers, + onPrinterSelected = { _ -> + // Printing not available on iOS simulator without a physical printer + }, + ) + } + } +} From 6c63ac15dc3d235f42df83cd2a698dca6190d9b5 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:33:51 +0100 Subject: [PATCH 03/16] Update .gitignore with complete Xcode/iOS patterns Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f89fa2d..fbc7f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,39 @@ lint/tmp/ # Claude Code local settings .claude/settings.local.json -# Xcode user-specific data +# 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 From be8068edc66390d018ea8ee97affc2381fc1440a Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:17:45 +0100 Subject: [PATCH 04/16] [UNTIL-19417] Fix iOS sample blank screen on iPad + implement Epson iOS printing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove INFOPLIST_KEY_UIApplicationSceneManifest_Generation from project.pbxproj to prevent auto-enabling UIApplicationSupportsMultipleScenes=true, which caused Compose to render into zero-size frames on iPad (Stage Manager / multi-window) - Add UIApplicationSceneManifest{UIApplicationSupportsMultipleScenes=false} and NSBluetoothAlwaysUsageDescription to Info.plist; without the Bluetooth key iOS silently terminates any app linked against CoreBluetooth on physical devices - Wrap Epos2Discovery.start() in withContext(Dispatchers.Main) — Epson SDK requires the iOS main thread for discovery start/stop - Implement EpsonPrinterController, EpsonPrintService, EpsonServiceProvider, StarServiceProvider, EpsonPrinterErrorState iOS actuals (replace TODO stubs) - Add nativeInterop cinterop def + README for Epson ePOS2 SDK integration - Wire up MainViewController to live printer discovery and print job dispatch Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + .../project.pbxproj | 27 ++- .../contents.xcworkspacedata | 0 iosApp/iosApp/ContentView.swift | 2 +- iosApp/iosApp/Info.plist | 16 ++ .../printengine/PrinterServiceProvider.ios.kt | 8 +- print-plugins/epson/build.gradle.kts | 56 +++++- print-plugins/epson/nativeInterop/README.md | 24 +++ .../epson/nativeInterop/cinterop/epos2.def | 3 + .../epson/EpsonPrintService.ios.kt | 126 +++++++++++- .../epson/EpsonPrinterController.ios.kt | 185 +++++++++++++++--- .../epson/EpsonPrinterDiscovery.ios.kt | 93 ++++++++- .../epson/EpsonPrinterErrorState.ios.kt | 81 ++++++++ .../epson/EpsonServiceProvider.ios.kt | 15 +- .../star/StarServiceProvider.ios.kt | 10 +- .../printengine/sample/MainViewController.kt | 111 ++++++++++- 16 files changed, 712 insertions(+), 49 deletions(-) rename iosApp/{iosApp.xcodeproj => 029578.xcodeproj}/project.pbxproj (88%) rename iosApp/{iosApp.xcodeproj => 029578.xcodeproj}/project.xcworkspace/contents.xcworkspacedata (100%) create mode 100644 print-plugins/epson/nativeInterop/README.md create mode 100644 print-plugins/epson/nativeInterop/cinterop/epos2.def create mode 100644 print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrinterErrorState.ios.kt diff --git a/.gitignore b/.gitignore index fbc7f6b..3cca52f 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ DerivedData/ .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/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/029578.xcodeproj/project.pbxproj similarity index 88% rename from iosApp/iosApp.xcodeproj/project.pbxproj rename to iosApp/029578.xcodeproj/project.pbxproj index 8f09a88..2b31937 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/029578.xcodeproj/project.pbxproj @@ -86,7 +86,7 @@ LastSwiftUpdateCheck = 1500; LastUpgradeCheck = 1500; }; - buildConfigurationList = 1D6E0B012B0A00000000x /* Build configuration list for PBXProject "iosApp" */; + buildConfigurationList = 1D6E0B012B0A00000000x /* Build configuration list for PBXProject "029578" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -131,7 +131,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$SRCROOT/..\"\n./gradlew :sample:embedAndSignAppleFrameworkForXcode\n"; + 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 */ @@ -205,22 +205,30 @@ 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_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + "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)"; @@ -236,22 +244,30 @@ 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_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + "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)"; @@ -264,7 +280,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 1D6E0B012B0A00000000x /* Build configuration list for PBXProject "iosApp" */ = { + 1D6E0B012B0A00000000x /* Build configuration list for PBXProject "029578" */ = { isa = XCConfigurationList; buildConfigurations = ( 1D6E0B122B0A00000000 /* Debug */, @@ -283,7 +299,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - }; rootObject = 1D6E0AFE2B0A00000000 /* Project object */; } diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/029578.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to iosApp/029578.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 4f56190..94dd236 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -13,6 +13,6 @@ struct ComposeView: UIViewControllerRepresentable { struct ContentView: View { var body: some View { ComposeView() - .ignoresSafeArea(.keyboard) + .ignoresSafeArea(.all) } } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1..ec124a8 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,21 @@ 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 + 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-plugins/epson/build.gradle.kts b/print-plugins/epson/build.gradle.kts index ba12d4b..6e94d7a 100644 --- a/print-plugins/epson/build.gradle.kts +++ b/print-plugins/epson/build.gradle.kts @@ -40,9 +40,59 @@ kotlin { } val xcfName = "print-engine-epson" - iosX64 { binaries.framework { baseName = xcfName } } - iosArm64 { binaries.framework { baseName = xcfName } } - iosSimulatorArm64 { binaries.framework { baseName = xcfName } } + val xcfLibsDir = "${project.projectDir}/nativeInterop/libs/ios" + + iosArm64 { + binaries.framework { baseName = xcfName } + compilations["main"].cinterops { + create("epos2") { + defFile = file("nativeInterop/cinterop/epos2.def") + includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_armv7/Headers") + } + } + binaries.all { + linkerOpts( + "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_armv7", + "-lepos2", + "-framework", "CoreBluetooth", + "-framework", "ExternalAccessory", + ) + } + } + iosX64 { + binaries.framework { baseName = xcfName } + compilations["main"].cinterops { + create("epos2") { + defFile = file("nativeInterop/cinterop/epos2.def") + includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator/Headers") + } + } + binaries.all { + linkerOpts( + "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator", + "-lepos2", + "-framework", "CoreBluetooth", + "-framework", "ExternalAccessory", + ) + } + } + iosSimulatorArm64 { + binaries.framework { baseName = xcfName } + compilations["main"].cinterops { + create("epos2") { + defFile = file("nativeInterop/cinterop/epos2.def") + includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator/Headers") + } + } + binaries.all { + linkerOpts( + "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator", + "-lepos2", + "-framework", "CoreBluetooth", + "-framework", "ExternalAccessory", + ) + } + } sourceSets { val commonMain by getting { diff --git a/print-plugins/epson/nativeInterop/README.md b/print-plugins/epson/nativeInterop/README.md new file mode 100644 index 0000000..ad570e0 --- /dev/null +++ b/print-plugins/epson/nativeInterop/README.md @@ -0,0 +1,24 @@ +# Epson iOS SDK Setup + +The `libepos2.xcframework` binary is **not committed to git** due to its size (~71 MB). + +## Download + +Run the following from the project root: + +```bash +brew install git-lfs +mkdir -p /tmp/epson_sdk && cd /tmp/epson_sdk +git lfs clone --depth 1 --branch 2.23.1 https://github.com/popina/EpsonSDK.git . +mkdir -p /print-plugins/epson/nativeInterop/libs/ios +cp -r libepos2.xcframework /print-plugins/epson/nativeInterop/libs/ios/ +``` + +## SDK version + +| SDK Version | Tested | +|-------------|--------| +| 2.23.1 | ✅ | + +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 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/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..c1b2ea2 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,129 @@ package de.tillhub.printengine.epson +import co.touchlab.kermit.Logger +import com.epson.epos2.Epos2PrinterStatusInfo +import com.epson.epos2.Epos2PtrReceiveDelegateProtocol +import com.epson.epos2.Epos2Printer +import kotlinx.cinterop.ExperimentalForeignApi import de.tillhub.printengine.PrintService import de.tillhub.printengine.PrinterController +import de.tillhub.printengine.data.ExternalPrinter import de.tillhub.printengine.data.PrinterState 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 -> throw IllegalArgumentException("Unsupported printer type: $this") + } + + // SDK 2.23.1 series constants (values match the NS_ENUM order in ePOS2.h) + private companion object { + const val EPOS2_TM_M10 = 0 + const val EPOS2_TM_M30 = 1 + const val EPOS2_TM_P20 = 2 + const val EPOS2_TM_P60 = 3 + const val EPOS2_TM_P60II = 4 + const val EPOS2_TM_P80 = 5 + const val EPOS2_TM_T20 = 6 + const val EPOS2_TM_T60 = 7 + const val EPOS2_TM_T70 = 8 + const val EPOS2_TM_T81 = 9 + const val EPOS2_TM_T82 = 10 + const val EPOS2_TM_T83 = 11 + const val EPOS2_TM_T88 = 12 + const val EPOS2_TM_T90 = 13 + const val EPOS2_TM_T90KP = 14 + const val EPOS2_TM_U220 = 15 + const val EPOS2_TM_U330 = 16 + const val EPOS2_TM_L90 = 17 + const val EPOS2_TM_H6000 = 18 + const val EPOS2_TM_T83III = 19 + const val EPOS2_TM_T100 = 20 + const val EPOS2_TM_M30II = 21 + const val EPOS2_TS_100 = 22 + const val EPOS2_TM_M50 = 23 + const val EPOS2_TM_T88VII = 24 + const val EPOS2_TM_L90LFC = 25 + const val EPOS2_TM_L100 = 26 + const val EPOS2_TM_P20II = 27 + const val EPOS2_TM_P80II = 28 + const val EPOS2_TM_M30III = 29 + const val EPOS2_MODEL_ANK = 0 + } } 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 12e7584..34f2429 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,195 @@ package de.tillhub.printengine.epson import androidx.compose.ui.graphics.ImageBitmap +import com.epson.epos2.Epos2Printer import de.tillhub.printengine.PrinterController +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 kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import org.jetbrains.skia.Bitmap as SkiaBitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image as SkiaImage +import org.jetbrains.skia.ImageInfo +import platform.Foundation.NSData +import platform.Foundation.create +import platform.UIKit.UIImage +import platform.posix.memcpy -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 { + epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + epsonPrinter.addCommand(data.bytes.toNSData()) } - actual override fun observePrinterState(): Flow { - TODO("Not yet implemented") + actual override fun observePrinterState(): Flow = printerState + + actual override fun setFontSize(fontSize: PrintingFontType) = executeEpsonCommand { + epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + epsonPrinter.addTextFont( + when (fontSize) { + PrintingFontType.DEFAULT_FONT_SIZE -> EPOS2_FONT_A + }, + ) } - actual override fun setFontSize(fontSize: PrintingFontType) { - TODO("Not yet implemented") + actual override fun printText(text: String) = executeEpsonCommand { + epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + epsonPrinter.addText("$text\n") } - actual override fun printText(text: String) { - TODO("Not yet implemented") + actual override fun printBarcode(barcode: String) = executeEpsonCommand { + epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + // "{B" forces Code Set B — same convention as Android + epsonPrinter.addBarcode( + "{B$barcode", + EPOS2_BARCODE_CODE128, + EPOS2_HRI_BELOW, + EPOS2_FONT_A, + BARCODE_MODULE_WIDTH.toLong(), + BARCODE_HEIGHT.toLong(), + ) + epsonPrinter.addFeedLine(THREE_FEED_LINES.toLong()) } - actual override fun printBarcode(barcode: String) { - TODO("Not yet implemented") + actual override fun printQr(qrData: String) = executeEpsonCommand { + epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + epsonPrinter.addSymbol( + qrData, + EPOS2_SYMBOL_QRCODE_MODEL_1, + EPOS2_LEVEL_M, + QR_DIMENSION.toLong(), + QR_DIMENSION.toLong(), + EPOS2_PARAM_UNSPECIFIED.toLong(), + ) } - actual override fun printQr(qrData: String) { - TODO("Not yet implemented") + actual override fun printImage(image: ImageBitmap) = executeEpsonCommand { + epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + val uiImage = image.toUIImage() ?: return@executeEpsonCommand + 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, + ) } - actual override fun printImage(image: ImageBitmap) { - TODO("Not yet implemented") + actual override fun feedPaper() = executeEpsonCommand { + epsonPrinter.addFeedLine(ONE_FEED_LINE.toLong()) } - actual override fun feedPaper() { - TODO("Not yet implemented") + actual override fun cutPaper() = executeEpsonCommand { + epsonPrinter.addCut(EPOS2_CUT_NO_FEED) } - actual override fun cutPaper() { - TODO("Not yet implemented") + actual override fun setIntensity(intensity: PrintingIntensity) = Unit + + actual override fun start() { + executeEpsonCommand { + val status = epsonPrinter.getStatus() + if (status?.connection != EPOS2_TRUE) { + epsonPrinter.connect(printerData.getTarget(), EPOS2_PARAM_DEFAULT.toLong()) + } + epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + epsonPrinter.sendData(EPOS2_PARAM_DEFAULT.toLong()) + } + epsonPrinter.clearCommandBuffer() + epsonPrinter.disconnect() } - actual override fun setIntensity(intensity: PrintingIntensity) { - TODO("Not yet implemented") + actual override suspend fun getPrinterInfo(): PrinterInfo = printerData.info + + private fun executeEpsonCommand(command: () -> Unit) { + try { + command.invoke() + } catch (e: Exception) { + printerState.value = EpsonPrinterErrorState.epsonExceptionToState(e) + epsonPrinter.clearCommandBuffer() + } } - actual override fun start() { - 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()) + } } - actual override suspend fun getPrinterInfo(): PrinterInfo { - TODO("Not yet implemented") + @OptIn(ExperimentalForeignApi::class) + private fun ImageBitmap.toUIImage(): UIImage? { + val w = width + val h = height + val buffer = IntArray(w * h) + readPixels(buffer) + + 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) + } + val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap) + val pngData = skiaImage.encodeToData(EncodedImageFormat.PNG) ?: return null + + val nsData = pngData.bytes.usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = pngData.bytes.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 + + // Constants matching ePOS2.h enum values + const val EPOS2_TRUE = 1 + const val EPOS2_PARAM_DEFAULT = -2 + const val EPOS2_PARAM_UNSPECIFIED = -1 + const val EPOS2_ALIGN_CENTER = 1 + const val EPOS2_FONT_A = 0 + const val EPOS2_COLOR_1 = 1 + const val EPOS2_MODE_MONO = 0 + const val EPOS2_HALFTONE_DITHER = 0 + const val EPOS2_COMPRESS_AUTO = 2 + const val EPOS2_BARCODE_CODE128 = 10 + const val EPOS2_HRI_BELOW = 2 + const val EPOS2_SYMBOL_QRCODE_MODEL_1 = 2 // EPOS2_SYMBOL_QRCODE_MODEL_1 (PDF417_STANDARD=0, PDF417_TRUNCATED=1, QRCODE_MODEL_1=2) + const val EPOS2_LEVEL_M = 10 // EPOS2_LEVEL_M (LEVEL_0..LEVEL_8=0..8, LEVEL_L=9, LEVEL_M=10) + const val EPOS2_CUT_NO_FEED = 1 } } 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..0626e13 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,101 @@ 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 kotlinx.cinterop.ExperimentalForeignApi +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.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: EPOS2_PORTTYPE_ALL=0, EPOS2_TYPE_PRINTER=1 (first after TYPE_ALL=0), EPOS2_MODEL_ALL=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 + } + + 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() + } + } + + 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() + + // ePOS2.h: EPOS2_SUCCESS = 0 (first value in Epos2ErrorStatus enum) + private val EPOS2_SUCCESS = 0 } 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..41d0257 --- /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.Epos2PrinterStatusInfo +import de.tillhub.printengine.data.PrinterState +import kotlinx.cinterop.ExperimentalForeignApi + +@OptIn(ExperimentalForeignApi::class) +internal object EpsonPrinterErrorState { + + // ePOS2.h error codes (Epos2ErrorStatus enum values) + private const val EPOS2_SUCCESS = 0 + private const val EPOS2_ERR_PARAM = 1 + private const val EPOS2_ERR_CONNECT = 2 + private const val EPOS2_ERR_TIMEOUT = 3 + private const val EPOS2_ERR_MEMORY = 4 + private const val EPOS2_ERR_DISCONNECT = 10 + + // ePOS2.h callback codes (Epos2CallbackCode enum values) + private const val EPOS2_CODE_SUCCESS = 0 + private const val EPOS2_CODE_PRINTING = 1 + + // ePOS2.h status constants + private const val EPOS2_FALSE = 0 + private const val EPOS2_TRUE = 1 + private const val EPOS2_PAPER_EMPTY = 2 // EPOS2_PAPER_OK=0, NEAR_END=1, EMPTY=2 + private const val EPOS2_MECHANICAL_ERR = 0 // Epos2ErrorStatusDetail enum + private const val EPOS2_AUTOCUTTER_ERR = 1 + private const val EPOS2_UNRECOVER_ERR = 2 + private const val EPOS2_AUTORECOVER_ERR = 3 + private const val EPOS2_BATTERY_LEVEL_0 = 0 + private const val EPOS2_HEAD_OVERHEAT = 0 // Epos2AutoRecoverError enum + private const val EPOS2_MOTOR_OVERHEAT = 1 + private const val EPOS2_BATTERY_OVERHEAT = 2 + private const val EPOS2_WRONG_PAPER = 3 + private const val EPOS2_COVER_OPEN = 4 + private const val EPOS2_LOW_VOLTAGE_ERR = 0 // Epos2UnrecoverError enum + + fun epsonExceptionToState(e: Exception): PrinterState = when { + e.message?.contains("ERR_PARAM") == true -> PrinterState.Error.Epson.DataError + e.message?.contains("ERR_CONNECT") == true -> PrinterState.Error.NotAvailable + e.message?.contains("ERR_TIMEOUT") == true -> PrinterState.Error.ConnectionLost + e.message?.contains("ERR_DISCONNECT") == true -> PrinterState.Error.ConnectionLost + e.message?.contains("ERR_MEMORY") == true -> 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_MECHANICAL_ERR -> 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 -> 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/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/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt index 88c6f77..a8dec22 100644 --- a/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt +++ b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt @@ -3,26 +3,66 @@ 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.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember 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.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import platform.UIKit.UIViewController +private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + @Suppress("ktlint:standard:function-naming") fun MainViewController(): UIViewController = ComposeUIViewController { - remember { + val printEngine = remember { PrintEngine.getInstance().also { it.printer.settings.printingIntensity = PrintingIntensity.DEFAULT } } + val printers = remember { mutableStateListOf() } + var initialized = remember { false } + + // Start printer discovery + remember { + scope.launch(Dispatchers.IO) { + printEngine.discoverExternalPrinters( + EpsonPrinterDiscovery(), + ).collect { state -> + when (state) { + is DiscoveryState.Discovering -> { + printers.clear() + printers.addAll(state.printers) + } + is DiscoveryState.Finished -> { + printers.clear() + printers.addAll(state.printers) + } + else -> Unit + } + } + } + } + + val printState by printEngine.printer.printerState + .collectAsState(PrinterState.CheckingForPrinter) TillhubPrintEngineTheme { Surface( @@ -30,12 +70,75 @@ fun MainViewController(): UIViewController = ComposeUIViewController { color = MaterialTheme.colorScheme.background, ) { PrinterLayout( - printState = PrinterState.CheckingForPrinter, + printState = printState, printers = printers, - onPrinterSelected = { _ -> - // Printing not available on iOS simulator without a physical printer + onPrinterSelected = { selectedPrinter -> + scope.launch { + if (!initialized && selectedPrinter != null) { + initialized = true + printEngine.initPrinter { + EpsonServiceProvider.build(printer = selectedPrinter) + } + } + 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, + ), +) From 8444ac482cd4bc0a8ceb9d8a98ac3efd82daaa28 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:34:33 +0100 Subject: [PATCH 05/16] [UNTIL-19417] Fix SDK constants, error handling, resource leaks, and thread safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix all ePOS2.h enum constants to match actual SDK header values: EPOS2_MECHANICAL_ERR 0→1, AUTOCUTTER_ERR 1→2, UNRECOVER_ERR 2→3, AUTORECOVER_ERR 3→4, CODE_PRINTING 1→13, LOW_VOLTAGE_ERR 0→1 - Add EPOS2_NO_ERR and EPOS2_UNKNOWN checks in evaluateStatusInfo - Check every Epson SDK return code via checkSdkResult() — iOS SDK returns int error codes unlike Android which throws Epos2Exception - Guard disconnect() — only call if connect() succeeded - Close SkiaBitmap/SkiaImage after use to prevent native memory leaks - Extract shared ImageBitmap.encodeToPngBytes() utility - Replace remember{scope.launch} with LaunchedEffect(Unit) - Use flowOn(IO) + collect on Main to avoid mutating Compose state from background dispatcher - Replace leaked module-level CoroutineScope with rememberCoroutineScope() - Guard startPrintJob behind initialized check - Fall back to TM-T88 for unknown printer models instead of crashing - Close SkiaImage in BarcodeEncoderImpl after toComposeImageBitmap() Co-Authored-By: Claude Opus 4.6 (1M context) --- .../barcode/BarcodeEncoderImpl.ios.kt | 11 +- .../printengine/data/ImageBitmap.ios.kt | 41 +++- .../epson/EpsonPrintService.ios.kt | 5 +- .../epson/EpsonPrinterController.ios.kt | 208 ++++++++++-------- .../epson/EpsonPrinterDiscovery.ios.kt | 40 ++-- .../epson/EpsonPrinterErrorState.ios.kt | 94 ++++---- .../printengine/sample/MainViewController.kt | 56 ++--- 7 files changed, 263 insertions(+), 192 deletions(-) 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 3f19aa2..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 @@ -92,7 +92,12 @@ internal actual class BarcodeEncoderImpl : BarcodeEncoder { // Convert NSData → ByteArray → Skia Image → Compose ImageBitmap val bytes = pngData.toByteArray() return try { - SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() + val skiaImage = SkiaImage.makeFromEncoded(bytes) + try { + skiaImage.toComposeImageBitmap() + } finally { + skiaImage.close() + } } catch (e: Exception) { Logger.e("Failed to decode barcode image", e) null @@ -101,8 +106,8 @@ internal actual class BarcodeEncoderImpl : BarcodeEncoder { /** * Creates a CIFilter via ObjC runtime +[CIFilter filterWithName:]. - * K/N bindings don't expose this factory method, so we call it - * via performSelector on the CIFilter class object. + * 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") 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 71d2c8b..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 @@ -12,12 +12,17 @@ import kotlin.io.encoding.ExperimentalEncodingApi import org.jetbrains.skia.Bitmap as SkiaBitmap import org.jetbrains.skia.Image as SkiaImage -@OptIn(ExperimentalEncodingApi::class, ExperimentalForeignApi::class) -actual fun ImageBitmap.encodeToBase64(): String { - val width = this.width - val height = this.height - val buffer = IntArray(width * height) - this.readPixels(buffer) +/** + * 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. @@ -28,15 +33,27 @@ actual fun ImageBitmap.encodeToBase64(): String { } } - val imageInfo = ImageInfo.makeN32Premul(width, height) + val imageInfo = ImageInfo.makeN32Premul(w, h) val skiaBitmap = SkiaBitmap().apply { allocPixels(imageInfo) - installPixels(imageInfo, bytes, width * 4) + installPixels(imageInfo, bytes, w * 4) } - val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap) - val pngData = skiaImage.encodeToData(EncodedImageFormat.PNG) - ?: error("Failed to encode image to PNG") + return try { + val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap) + try { + skiaImage.encodeToData(EncodedImageFormat.PNG)?.bytes + } finally { + skiaImage.close() + } + } finally { + skiaBitmap.close() + } +} - return Base64.encode(pngData.bytes) +@OptIn(ExperimentalEncodingApi::class) +actual fun ImageBitmap.encodeToBase64(): String { + val pngBytes = encodeToPngBytes() + ?: error("Failed to encode image to PNG") + return Base64.encode(pngBytes) } 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 c1b2ea2..0f79264 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 @@ -89,7 +89,10 @@ internal actual class EpsonPrintService( "EU-M30" -> EPOS2_TM_M30 "SB-H50" -> EPOS2_TM_T88 "TM PRINTER", "" -> EPOS2_TM_T88 - else -> throw IllegalArgumentException("Unsupported printer type: $this") + else -> { + logger.w { "Unknown printer model '$this', falling back to TM-T88" } + EPOS2_TM_T88 + } } // SDK 2.23.1 series constants (values match the NS_ENUM order in ePOS2.h) 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 34f2429..c37494f 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 @@ -9,19 +9,15 @@ 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 org.jetbrains.skia.Bitmap as SkiaBitmap -import org.jetbrains.skia.EncodedImageFormat -import org.jetbrains.skia.Image as SkiaImage -import org.jetbrains.skia.ImageInfo import platform.Foundation.NSData import platform.Foundation.create import platform.UIKit.UIImage -import platform.posix.memcpy @Suppress("TooManyFunctions") @OptIn(ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) @@ -32,99 +28,135 @@ internal actual class EpsonPrinterController( ) : PrinterController { actual override fun sendRawData(data: RawPrinterData) = executeEpsonCommand { - epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) - epsonPrinter.addCommand(data.bytes.toNSData()) + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + checkSdkResult(epsonPrinter.addCommand(data.bytes.toNSData()), "addCommand") } actual override fun observePrinterState(): Flow = printerState actual override fun setFontSize(fontSize: PrintingFontType) = executeEpsonCommand { - epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) - epsonPrinter.addTextFont( - when (fontSize) { - PrintingFontType.DEFAULT_FONT_SIZE -> EPOS2_FONT_A - }, + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + checkSdkResult( + epsonPrinter.addTextFont( + when (fontSize) { + PrintingFontType.DEFAULT_FONT_SIZE -> EPOS2_FONT_A + }, + ), + "addTextFont", ) } actual override fun printText(text: String) = executeEpsonCommand { - epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) - epsonPrinter.addText("$text\n") + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") + checkSdkResult(epsonPrinter.addText("$text\n"), "addText") } actual override fun printBarcode(barcode: String) = executeEpsonCommand { - epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") // "{B" forces Code Set B — same convention as Android - epsonPrinter.addBarcode( - "{B$barcode", - EPOS2_BARCODE_CODE128, - EPOS2_HRI_BELOW, - EPOS2_FONT_A, - BARCODE_MODULE_WIDTH.toLong(), - BARCODE_HEIGHT.toLong(), + checkSdkResult( + epsonPrinter.addBarcode( + "{B$barcode", + EPOS2_BARCODE_CODE128, + EPOS2_HRI_BELOW, + EPOS2_FONT_A, + BARCODE_MODULE_WIDTH.toLong(), + BARCODE_HEIGHT.toLong(), + ), + "addBarcode", ) - epsonPrinter.addFeedLine(THREE_FEED_LINES.toLong()) + checkSdkResult(epsonPrinter.addFeedLine(THREE_FEED_LINES.toLong()), "addFeedLine") } actual override fun printQr(qrData: String) = executeEpsonCommand { - epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) - epsonPrinter.addSymbol( - qrData, - EPOS2_SYMBOL_QRCODE_MODEL_1, - EPOS2_LEVEL_M, - QR_DIMENSION.toLong(), - QR_DIMENSION.toLong(), - EPOS2_PARAM_UNSPECIFIED.toLong(), + 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 printImage(image: ImageBitmap) = executeEpsonCommand { - epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) + checkSdkResult(epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER), "addTextAlign") val uiImage = image.toUIImage() ?: return@executeEpsonCommand - 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, + 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 feedPaper() = executeEpsonCommand { - epsonPrinter.addFeedLine(ONE_FEED_LINE.toLong()) + checkSdkResult(epsonPrinter.addFeedLine(ONE_FEED_LINE.toLong()), "addFeedLine") } actual override fun cutPaper() = executeEpsonCommand { - epsonPrinter.addCut(EPOS2_CUT_NO_FEED) + checkSdkResult(epsonPrinter.addCut(EPOS2_CUT_NO_FEED), "addCut") } actual override fun setIntensity(intensity: PrintingIntensity) = Unit actual override fun start() { + var connected = false executeEpsonCommand { val status = epsonPrinter.getStatus() if (status?.connection != EPOS2_TRUE) { - epsonPrinter.connect(printerData.getTarget(), EPOS2_PARAM_DEFAULT.toLong()) + checkSdkResult( + epsonPrinter.connect(printerData.getTarget(), EPOS2_PARAM_DEFAULT.toLong()), + "connect", + ) } - epsonPrinter.addTextAlign(EPOS2_ALIGN_CENTER) - epsonPrinter.sendData(EPOS2_PARAM_DEFAULT.toLong()) + connected = true + checkSdkResult(epsonPrinter.sendData(EPOS2_PARAM_DEFAULT.toLong()), "sendData") } epsonPrinter.clearCommandBuffer() - epsonPrinter.disconnect() + if (connected) { + try { + epsonPrinter.disconnect() + } catch (_: Exception) { + // Disconnect failure is non-critical; connection will be re-established next print + } + } } 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) + } + } + private fun executeEpsonCommand(command: () -> Unit) { try { command.invoke() + } catch (e: EpsonSdkException) { + printerState.value = EpsonPrinterErrorState.epsonErrorStatusToState(e.errorCode) + epsonPrinter.clearCommandBuffer() } catch (e: Exception) { - printerState.value = EpsonPrinterErrorState.epsonExceptionToState(e) + printerState.value = PrinterState.Error.Epson.InternalError epsonPrinter.clearCommandBuffer() } } @@ -141,55 +173,47 @@ internal actual class EpsonPrinterController( @OptIn(ExperimentalForeignApi::class) private fun ImageBitmap.toUIImage(): UIImage? { - val w = width - val h = height - val buffer = IntArray(w * h) - readPixels(buffer) - - 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) - } - val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap) - val pngData = skiaImage.encodeToData(EncodedImageFormat.PNG) ?: return null - - val nsData = pngData.bytes.usePinned { pinned -> - NSData.create(bytes = pinned.addressOf(0), length = pngData.bytes.size.toULong()) + 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 - - // Constants matching ePOS2.h enum values - const val EPOS2_TRUE = 1 - const val EPOS2_PARAM_DEFAULT = -2 + const val EPOS2_SUCCESS = 0 + + 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 + + // ePOS2.h enum values + const val EPOS2_TRUE = 1 + const val EPOS2_PARAM_DEFAULT = -2 const val EPOS2_PARAM_UNSPECIFIED = -1 - const val EPOS2_ALIGN_CENTER = 1 - const val EPOS2_FONT_A = 0 - const val EPOS2_COLOR_1 = 1 - const val EPOS2_MODE_MONO = 0 + const val EPOS2_ALIGN_CENTER = 1 + const val EPOS2_FONT_A = 0 + const val EPOS2_COLOR_1 = 1 + const val EPOS2_MODE_MONO = 0 const val EPOS2_HALFTONE_DITHER = 0 - const val EPOS2_COMPRESS_AUTO = 2 + const val EPOS2_COMPRESS_AUTO = 2 const val EPOS2_BARCODE_CODE128 = 10 - const val EPOS2_HRI_BELOW = 2 - const val EPOS2_SYMBOL_QRCODE_MODEL_1 = 2 // EPOS2_SYMBOL_QRCODE_MODEL_1 (PDF417_STANDARD=0, PDF417_TRUNCATED=1, QRCODE_MODEL_1=2) - const val EPOS2_LEVEL_M = 10 // EPOS2_LEVEL_M (LEVEL_0..LEVEL_8=0..8, LEVEL_L=9, LEVEL_M=10) - const val EPOS2_CUT_NO_FEED = 1 + const val EPOS2_HRI_BELOW = 2 + const val EPOS2_SYMBOL_QRCODE_MODEL_1 = 2 + const val EPOS2_LEVEL_M = 10 + const val EPOS2_CUT_NO_FEED = 1 } } + +/** + * 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 0626e13..6940266 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 @@ -26,10 +26,11 @@ actual class EpsonPrinterDiscovery : PrinterDiscovery { private companion object { const val CHARACTER_COUNT = 42 const val MANUFACTURER_EPSON = "EPSON" - // ePOS2.h: EPOS2_PORTTYPE_ALL=0, EPOS2_TYPE_PRINTER=1 (first after TYPE_ALL=0), EPOS2_MODEL_ALL=0 + // 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 + const val EPOS2_MODEL_ALL = 0 } actual override val observePrinters: Flow @@ -39,11 +40,12 @@ actual class EpsonPrinterDiscovery : PrinterDiscovery { val discoveredPrinters = mutableMapOf() val filterOption = Epos2FilterOption().apply { - portType = EPOS2_PORTTYPE_ALL - deviceType = EPOS2_TYPE_PRINTER + 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 @@ -63,6 +65,9 @@ actual class EpsonPrinterDiscovery : PrinterDiscovery { 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() } } @@ -70,22 +75,22 @@ actual class EpsonPrinterDiscovery : PrinterDiscovery { 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 + 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, + 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(), + manufacturer = MANUFACTURER_EPSON, + connectionAddress = address, + connectionType = protocol.toConnectionType(), ) } @@ -95,7 +100,4 @@ actual class EpsonPrinterDiscovery : PrinterDiscovery { private fun Epos2DeviceInfo.isValid(): Boolean = deviceType == EPOS2_TYPE_PRINTER && !deviceName.isNullOrEmpty() - - // ePOS2.h: EPOS2_SUCCESS = 0 (first value in Epos2ErrorStatus enum) - private val EPOS2_SUCCESS = 0 } 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 index 41d0257..8a47529 100644 --- 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 @@ -7,57 +7,70 @@ import kotlinx.cinterop.ExperimentalForeignApi @OptIn(ExperimentalForeignApi::class) internal object EpsonPrinterErrorState { - // ePOS2.h error codes (Epos2ErrorStatus enum values) - private const val EPOS2_SUCCESS = 0 - private const val EPOS2_ERR_PARAM = 1 - private const val EPOS2_ERR_CONNECT = 2 - private const val EPOS2_ERR_TIMEOUT = 3 - private const val EPOS2_ERR_MEMORY = 4 - private const val EPOS2_ERR_DISCONNECT = 10 + // ePOS2.h Epos2ErrorStatus enum (sequential from 0) + private const val EPOS2_SUCCESS = 0 + private const val EPOS2_ERR_PARAM = 1 + private const val EPOS2_ERR_CONNECT = 2 + private const val EPOS2_ERR_TIMEOUT = 3 + private const val EPOS2_ERR_MEMORY = 4 + private const val EPOS2_ERR_DISCONNECT = 10 - // ePOS2.h callback codes (Epos2CallbackCode enum values) - private const val EPOS2_CODE_SUCCESS = 0 - private const val EPOS2_CODE_PRINTING = 1 + // ePOS2.h Epos2CallbackCode enum (sequential from 0) + private const val EPOS2_CODE_SUCCESS = 0 + private const val EPOS2_CODE_PRINTING = 13 + + // ePOS2.h Epos2PrinterError enum + private const val EPOS2_NO_ERR = 0 + private const val EPOS2_MECHANICAL_ERR = 1 + private const val EPOS2_AUTOCUTTER_ERR = 2 + private const val EPOS2_UNRECOVER_ERR = 3 + private const val EPOS2_AUTORECOVER_ERR = 4 // ePOS2.h status constants - private const val EPOS2_FALSE = 0 - private const val EPOS2_TRUE = 1 - private const val EPOS2_PAPER_EMPTY = 2 // EPOS2_PAPER_OK=0, NEAR_END=1, EMPTY=2 - private const val EPOS2_MECHANICAL_ERR = 0 // Epos2ErrorStatusDetail enum - private const val EPOS2_AUTOCUTTER_ERR = 1 - private const val EPOS2_UNRECOVER_ERR = 2 - private const val EPOS2_AUTORECOVER_ERR = 3 + private const val EPOS2_FALSE = 0 + private const val EPOS2_TRUE = 1 + private const val EPOS2_PAPER_EMPTY = 2 // EPOS2_PAPER_OK=0, NEAR_END=1, EMPTY=2 private const val EPOS2_BATTERY_LEVEL_0 = 0 - private const val EPOS2_HEAD_OVERHEAT = 0 // Epos2AutoRecoverError enum - private const val EPOS2_MOTOR_OVERHEAT = 1 + + // ePOS2.h Epos2AutoRecoverError enum + private const val EPOS2_HEAD_OVERHEAT = 0 + private const val EPOS2_MOTOR_OVERHEAT = 1 private const val EPOS2_BATTERY_OVERHEAT = 2 - private const val EPOS2_WRONG_PAPER = 3 - private const val EPOS2_COVER_OPEN = 4 - private const val EPOS2_LOW_VOLTAGE_ERR = 0 // Epos2UnrecoverError enum + private const val EPOS2_WRONG_PAPER = 3 + private const val EPOS2_COVER_OPEN = 4 + + // ePOS2.h Epos2UnrecoverError enum + private const val EPOS2_HIGH_VOLTAGE_ERR = 0 + private const val EPOS2_LOW_VOLTAGE_ERR = 1 + + // ePOS2.h defines: #define EPOS2_UNKNOWN (-3) + private const val EPOS2_UNKNOWN = -3 - fun epsonExceptionToState(e: Exception): PrinterState = when { - e.message?.contains("ERR_PARAM") == true -> PrinterState.Error.Epson.DataError - e.message?.contains("ERR_CONNECT") == true -> PrinterState.Error.NotAvailable - e.message?.contains("ERR_TIMEOUT") == true -> PrinterState.Error.ConnectionLost - e.message?.contains("ERR_DISCONNECT") == true -> PrinterState.Error.ConnectionLost - e.message?.contains("ERR_MEMORY") == true -> PrinterState.Error.Epson.MemoryError + 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_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_MECHANICAL_ERR -> PrinterState.Error.Malfunctions - status.errorStatus == EPOS2_AUTOCUTTER_ERR -> PrinterState.Error.PaperCutterAbnormal - status.errorStatus == EPOS2_UNRECOVER_ERR -> handleUnrecoverableError(status) + 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 @@ -65,8 +78,9 @@ internal object EpsonPrinterErrorState { private fun handleUnrecoverableError(status: Epos2PrinterStatusInfo): PrinterState = when (status.unrecoverError) { - EPOS2_LOW_VOLTAGE_ERR -> PrinterState.Error.VoltageTooLow - else -> PrinterState.Error.Malfunctions + EPOS2_LOW_VOLTAGE_ERR, + EPOS2_HIGH_VOLTAGE_ERR -> PrinterState.Error.VoltageTooLow + else -> PrinterState.Error.Malfunctions } private fun handleAutoRecoverableError(status: Epos2PrinterStatusInfo): PrinterState = @@ -74,8 +88,8 @@ internal object EpsonPrinterErrorState { 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 + EPOS2_WRONG_PAPER -> PrinterState.Error.PaperAbnormal + EPOS2_COVER_OPEN -> PrinterState.Error.CoverNotClosed + else -> PrinterState.Error.Malfunctions } } diff --git a/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt index a8dec22..f7f1c92 100644 --- a/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt +++ b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt @@ -3,10 +3,14 @@ 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 @@ -20,15 +24,12 @@ 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import platform.UIKit.UIViewController -private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - @Suppress("ktlint:standard:function-naming") fun MainViewController(): UIViewController = ComposeUIViewController { val printEngine = remember { @@ -38,25 +39,24 @@ fun MainViewController(): UIViewController = ComposeUIViewController { } val printers = remember { mutableStateListOf() } - var initialized = remember { false } + var initialized by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() - // Start printer discovery - remember { - scope.launch(Dispatchers.IO) { - printEngine.discoverExternalPrinters( - EpsonPrinterDiscovery(), - ).collect { state -> - when (state) { - is DiscoveryState.Discovering -> { - printers.clear() - printers.addAll(state.printers) - } - is DiscoveryState.Finished -> { - printers.clear() - printers.addAll(state.printers) - } - else -> Unit + // Collect discovery flow on IO, but update Compose state on Main via flowOn + LaunchedEffect(Unit) { + printEngine.discoverExternalPrinters( + EpsonPrinterDiscovery(), + ).flowOn(Dispatchers.IO).collect { state -> + when (state) { + is DiscoveryState.Discovering -> { + printers.clear() + printers.addAll(state.printers) } + is DiscoveryState.Finished -> { + printers.clear() + printers.addAll(state.printers) + } + else -> Unit } } } @@ -75,12 +75,18 @@ fun MainViewController(): UIViewController = ComposeUIViewController { onPrinterSelected = { selectedPrinter -> scope.launch { if (!initialized && selectedPrinter != null) { - initialized = true - printEngine.initPrinter { - EpsonServiceProvider.build(printer = selectedPrinter) + try { + printEngine.initPrinter { + EpsonServiceProvider.build(printer = selectedPrinter) + } + initialized = true + } catch (_: Exception) { + return@launch } } - printEngine.printer.startPrintJob(PRINT_JOB) + if (initialized) { + printEngine.printer.startPrintJob(PRINT_JOB) + } } }, ) From 8f6f36636cc442775e75ab3956dca1978b455486 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:40:00 +0100 Subject: [PATCH 06/16] [UNTIL-19514] Add discovery status display and ExternalAccessory protocol - Show discovery state (searching/found/error) in the sample UI so connection issues are immediately visible on device - Add UISupportedExternalAccessoryProtocols with com.epson.escpos to Info.plist for MFi Bluetooth/USB printer discovery - Fix initialized flag to use mutableStateOf so it survives recomposition Co-Authored-By: Claude Opus 4.6 (1M context) --- iosApp/iosApp/Info.plist | 4 ++++ .../de/tillhub/printengine/sample/ui/PrinterLayout.kt | 9 +++++++++ .../tillhub/printengine/sample/MainViewController.kt | 10 +++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index ec124a8..2fca73a 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -20,5 +20,9 @@ _ipp._tcp _ipps._tcp + UISupportedExternalAccessoryProtocols + + com.epson.escpos + 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 index f7f1c92..020127a 100644 --- a/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt +++ b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt @@ -40,6 +40,7 @@ fun MainViewController(): UIViewController = ComposeUIViewController { val printers = remember { mutableStateListOf() } var initialized by remember { mutableStateOf(false) } + var discoveryStatus by remember { mutableStateOf("Searching for printers…") } val scope = rememberCoroutineScope() // Collect discovery flow on IO, but update Compose state on Main via flowOn @@ -48,15 +49,20 @@ fun MainViewController(): UIViewController = ComposeUIViewController { EpsonPrinterDiscovery(), ).flowOn(Dispatchers.IO).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}" } - else -> Unit } } } @@ -71,6 +77,7 @@ fun MainViewController(): UIViewController = ComposeUIViewController { ) { PrinterLayout( printState = printState, + discoveryStatus = discoveryStatus, printers = printers, onPrinterSelected = { selectedPrinter -> scope.launch { @@ -81,6 +88,7 @@ fun MainViewController(): UIViewController = ComposeUIViewController { } initialized = true } catch (_: Exception) { + discoveryStatus = "Failed to initialize printer" return@launch } } From 323cc347982987c9f36adb443b483f0fcebe7bc3 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:49:05 +0100 Subject: [PATCH 07/16] =?UTF-8?q?[UNTIL-19417]=20Remove=20discovery=20code?= =?UTF-8?q?=20=E2=80=94=20moved=20to=20UNTIL-19514=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip all printer discovery code from this branch to keep it focused on core iOS printing infrastructure. Discovery will be added as a separate feature in the UNTIL-19514 branch. Removed: - EpsonPrinterDiscovery.ios.kt implementation (reverted to TODO stub) - Discovery flow collection and discoveryStatus from MainViewController - discoveryStatus parameter from PrinterLayout - Discovery permissions from Info.plist (Bluetooth, LocalNetwork, Bonjour, ExternalAccessory) Co-Authored-By: Claude Opus 4.6 (1M context) --- iosApp/iosApp/Info.plist | 15 --- .../epson/EpsonPrinterDiscovery.ios.kt | 95 +------------------ .../printengine/sample/ui/PrinterLayout.kt | 9 -- .../printengine/sample/MainViewController.kt | 33 ------- 4 files changed, 1 insertion(+), 151 deletions(-) diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 2fca73a..b37a1ff 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -9,20 +9,5 @@ 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/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 6940266..08c0553 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,103 +1,10 @@ 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 kotlinx.cinterop.ExperimentalForeignApi -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.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() = 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() + get() = TODO("Not yet implemented") } 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 924e16b..506f1f6 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,7 +30,6 @@ fun PrinterLayout( printState: PrinterState, printers: List, onPrinterSelected: (ExternalPrinter?) -> Unit, - discoveryStatus: String? = null, ) { Column( modifier = Modifier.fillMaxSize(), @@ -42,14 +41,6 @@ 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 index 020127a..3be48d9 100644 --- a/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt +++ b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt @@ -3,7 +3,6 @@ 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 @@ -14,19 +13,14 @@ 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.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import platform.UIKit.UIViewController @@ -40,33 +34,8 @@ fun MainViewController(): UIViewController = ComposeUIViewController { val printers = remember { mutableStateListOf() } var initialized by remember { mutableStateOf(false) } - var discoveryStatus by remember { mutableStateOf("Searching for printers…") } val scope = rememberCoroutineScope() - // Collect discovery flow on IO, but update Compose state on Main via flowOn - LaunchedEffect(Unit) { - printEngine.discoverExternalPrinters( - EpsonPrinterDiscovery(), - ).flowOn(Dispatchers.IO).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) @@ -77,7 +46,6 @@ fun MainViewController(): UIViewController = ComposeUIViewController { ) { PrinterLayout( printState = printState, - discoveryStatus = discoveryStatus, printers = printers, onPrinterSelected = { selectedPrinter -> scope.launch { @@ -88,7 +56,6 @@ fun MainViewController(): UIViewController = ComposeUIViewController { } initialized = true } catch (_: Exception) { - discoveryStatus = "Failed to initialize printer" return@launch } } From 5c12e9b47d6f59a2fc0ac60040838fce22de7fbc Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:54:29 +0100 Subject: [PATCH 08/16] [UNTIL-19514] Add Epson printer discovery for iOS Implement full Epson printer discovery on iOS using the ePOS2 SDK: - EpsonPrinterDiscovery.ios.kt: callbackFlow-based discovery using Epos2Discovery with Epos2FilterOption (all ports, printer type). Dispatches start() to Main thread, references delegate in awaitClose to prevent GC, maps device info to ExternalPrinter model - MainViewController: LaunchedEffect-based discovery flow collection with discoveryStatus state (Idle/Discovering/Error), flowOn(IO) for background work with Main-thread Compose state updates - PrinterLayout: discoveryStatus parameter showing search progress - Info.plist: NSBluetoothAlwaysUsageDescription, NSLocalNetworkUsageDescription, NSBonjourServices, UISupportedExternalAccessoryProtocols for Bluetooth/LAN/USB printer discovery permissions Co-Authored-By: Claude Opus 4.6 (1M context) --- iosApp/iosApp/Info.plist | 15 +++ .../epson/EpsonPrinterDiscovery.ios.kt | 95 ++++++++++++++++++- .../printengine/sample/ui/PrinterLayout.kt | 9 ++ .../printengine/sample/MainViewController.kt | 33 +++++++ 4 files changed, 151 insertions(+), 1 deletion(-) diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index b37a1ff..2fca73a 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -9,5 +9,20 @@ 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/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..6940266 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,103 @@ 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 kotlinx.cinterop.ExperimentalForeignApi +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.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/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 index 3be48d9..020127a 100644 --- a/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt +++ b/sample/src/iosMain/kotlin/de/tillhub/printengine/sample/MainViewController.kt @@ -3,6 +3,7 @@ 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 @@ -13,14 +14,19 @@ 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.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import platform.UIKit.UIViewController @@ -34,8 +40,33 @@ fun MainViewController(): UIViewController = ComposeUIViewController { val printers = remember { mutableStateListOf() } var initialized by remember { mutableStateOf(false) } + var discoveryStatus by remember { mutableStateOf("Searching for printers…") } val scope = rememberCoroutineScope() + // Collect discovery flow on IO, but update Compose state on Main via flowOn + LaunchedEffect(Unit) { + printEngine.discoverExternalPrinters( + EpsonPrinterDiscovery(), + ).flowOn(Dispatchers.IO).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) @@ -46,6 +77,7 @@ fun MainViewController(): UIViewController = ComposeUIViewController { ) { PrinterLayout( printState = printState, + discoveryStatus = discoveryStatus, printers = printers, onPrinterSelected = { selectedPrinter -> scope.launch { @@ -56,6 +88,7 @@ fun MainViewController(): UIViewController = ComposeUIViewController { } initialized = true } catch (_: Exception) { + discoveryStatus = "Failed to initialize printer" return@launch } } From b99ae893327e32364b8774e1400b3b644f0dd690 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:30:08 +0100 Subject: [PATCH 09/16] [UNTIL-19417] Clean up Epson build config and auto-download SDK - Add Gradle task to auto-download Epson ePOS SDK xcframework on first iOS build, eliminating manual setup steps - Consolidate SDK config into EpsonSdk object with centralized version, repo URL, and architecture slice constants - DRY up iOS target configuration with configureEpsonInterop() extension - Move EpsonServiceProviderTest from instrumented to unit test with proper null-guard coverage - Update nativeInterop README with auto-download instructions Co-Authored-By: Claude Opus 4.6 (1M context) --- print-plugins/epson/build.gradle.kts | 112 ++++++++++-------- print-plugins/epson/nativeInterop/README.md | 33 ++++-- .../epson/EpsonServiceProviderTest.kt | 24 ---- .../epson/EpsonServiceProviderTest.kt | 46 +++++++ 4 files changed, 134 insertions(+), 81 deletions(-) delete mode 100644 print-plugins/epson/src/androidInstrumentedTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt create mode 100644 print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt diff --git a/print-plugins/epson/build.gradle.kts b/print-plugins/epson/build.gradle.kts index 6e94d7a..1686ee7 100644 --- a/print-plugins/epson/build.gradle.kts +++ b/print-plugins/epson/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.konan.target.KonanTarget plugins { alias(libs.plugins.androidLibrary) @@ -8,14 +9,65 @@ plugins { 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) } @@ -23,70 +75,30 @@ 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" - val xcfLibsDir = "${project.projectDir}/nativeInterop/libs/ios" - - iosArm64 { - binaries.framework { baseName = xcfName } - compilations["main"].cinterops { - create("epos2") { - defFile = file("nativeInterop/cinterop/epos2.def") - includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_armv7/Headers") - } - } - binaries.all { - linkerOpts( - "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_armv7", - "-lepos2", - "-framework", "CoreBluetooth", - "-framework", "ExternalAccessory", - ) - } - } - iosX64 { - binaries.framework { baseName = xcfName } - compilations["main"].cinterops { - create("epos2") { - defFile = file("nativeInterop/cinterop/epos2.def") - includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator/Headers") - } - } - binaries.all { - linkerOpts( - "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator", - "-lepos2", - "-framework", "CoreBluetooth", - "-framework", "ExternalAccessory", - ) - } - } - 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") { - defFile = file("nativeInterop/cinterop/epos2.def") - includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator/Headers") + definitionFile = file("nativeInterop/cinterop/epos2.def") + includeDirs("${epsonXcframeworkDir}/$slice/Headers") } } binaries.all { linkerOpts( - "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator", + "-L${epsonXcframeworkDir}/$slice", "-lepos2", "-framework", "CoreBluetooth", "-framework", "ExternalAccessory", @@ -94,6 +106,10 @@ kotlin { } } + iosArm64 { configureEpsonInterop() } + iosX64 { configureEpsonInterop() } + iosSimulatorArm64 { configureEpsonInterop() } + sourceSets { val commonMain by getting { dependencies { diff --git a/print-plugins/epson/nativeInterop/README.md b/print-plugins/epson/nativeInterop/README.md index ad570e0..0017b55 100644 --- a/print-plugins/epson/nativeInterop/README.md +++ b/print-plugins/epson/nativeInterop/README.md @@ -2,23 +2,38 @@ The `libepos2.xcframework` binary is **not committed to git** due to its size (~71 MB). -## Download +## Automatic download -Run the following from the project root: +The SDK is downloaded automatically on first iOS build. No manual steps needed: ```bash -brew install git-lfs -mkdir -p /tmp/epson_sdk && cd /tmp/epson_sdk -git lfs clone --depth 1 --branch 2.23.1 https://github.com/popina/EpsonSDK.git . -mkdir -p /print-plugins/epson/nativeInterop/libs/ios -cp -r libepos2.xcframework /print-plugins/epson/nativeInterop/libs/ios/ +./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 | ✅ | +| 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 +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/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/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..f0573e6 --- /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, + ) + } +} \ No newline at end of file From db1b743dd6b5282ee7bc69f63a7adfc2be5bbb98 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:30:08 +0100 Subject: [PATCH 10/16] [UNTIL-19417] Clean up Epson build config and auto-download SDK - Add Gradle task to auto-download Epson ePOS SDK xcframework on first iOS build, eliminating manual setup steps - Consolidate SDK config into EpsonSdk object with centralized version, repo URL, and architecture slice constants - DRY up iOS target configuration with configureEpsonInterop() extension - Move EpsonServiceProviderTest from instrumented to unit test with proper null-guard coverage - Update nativeInterop README with auto-download instructions Co-Authored-By: Claude Opus 4.6 (1M context) --- print-plugins/epson/build.gradle.kts | 130 +++++++++++------- print-plugins/epson/nativeInterop/README.md | 33 +++-- .../epson/EpsonServiceProviderTest.kt | 24 ---- .../epson/EpsonServiceProviderTest.kt | 46 +++++++ .../epson/EpsonPrintService.ios.kt | 112 +++++++-------- .../epson/EpsonPrinterErrorState.ios.kt | 41 +++--- 6 files changed, 226 insertions(+), 160 deletions(-) delete mode 100644 print-plugins/epson/src/androidInstrumentedTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt create mode 100644 print-plugins/epson/src/androidUnitTest/kotlin/de/tillhub/printengine/epson/EpsonServiceProviderTest.kt diff --git a/print-plugins/epson/build.gradle.kts b/print-plugins/epson/build.gradle.kts index 6e94d7a..aeb6bbd 100644 --- a/print-plugins/epson/build.gradle.kts +++ b/print-plugins/epson/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.konan.target.KonanTarget plugins { alias(libs.plugins.androidLibrary) @@ -8,14 +9,77 @@ plugins { 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) } @@ -23,77 +87,43 @@ 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" - val xcfLibsDir = "${project.projectDir}/nativeInterop/libs/ios" - - iosArm64 { - 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") { - defFile = file("nativeInterop/cinterop/epos2.def") - includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_armv7/Headers") + definitionFile = file("nativeInterop/cinterop/epos2.def") + includeDirs("$epsonXcframeworkDir/$slice/Headers") } } binaries.all { linkerOpts( - "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_armv7", + "-L$epsonXcframeworkDir/$slice", "-lepos2", - "-framework", "CoreBluetooth", - "-framework", "ExternalAccessory", - ) - } - } - iosX64 { - binaries.framework { baseName = xcfName } - compilations["main"].cinterops { - create("epos2") { - defFile = file("nativeInterop/cinterop/epos2.def") - includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator/Headers") - } - } - binaries.all { - linkerOpts( - "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator", - "-lepos2", - "-framework", "CoreBluetooth", - "-framework", "ExternalAccessory", - ) - } - } - iosSimulatorArm64 { - binaries.framework { baseName = xcfName } - compilations["main"].cinterops { - create("epos2") { - defFile = file("nativeInterop/cinterop/epos2.def") - includeDirs("$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator/Headers") - } - } - binaries.all { - linkerOpts( - "-L$xcfLibsDir/libepos2.xcframework/ios-arm64_i386_x86_64-simulator", - "-lepos2", - "-framework", "CoreBluetooth", - "-framework", "ExternalAccessory", + "-framework", + "CoreBluetooth", + "-framework", + "ExternalAccessory", ) } } + iosArm64 { configureEpsonInterop() } + iosX64 { configureEpsonInterop() } + iosSimulatorArm64 { configureEpsonInterop() } + sourceSets { val commonMain by getting { dependencies { diff --git a/print-plugins/epson/nativeInterop/README.md b/print-plugins/epson/nativeInterop/README.md index ad570e0..0017b55 100644 --- a/print-plugins/epson/nativeInterop/README.md +++ b/print-plugins/epson/nativeInterop/README.md @@ -2,23 +2,38 @@ The `libepos2.xcframework` binary is **not committed to git** due to its size (~71 MB). -## Download +## Automatic download -Run the following from the project root: +The SDK is downloaded automatically on first iOS build. No manual steps needed: ```bash -brew install git-lfs -mkdir -p /tmp/epson_sdk && cd /tmp/epson_sdk -git lfs clone --depth 1 --branch 2.23.1 https://github.com/popina/EpsonSDK.git . -mkdir -p /print-plugins/epson/nativeInterop/libs/ios -cp -r libepos2.xcframework /print-plugins/epson/nativeInterop/libs/ios/ +./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 | ✅ | +| 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 +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/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/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/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt index 0f79264..6e7f859 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,14 +1,14 @@ package de.tillhub.printengine.epson import co.touchlab.kermit.Logger +import com.epson.epos2.Epos2Printer import com.epson.epos2.Epos2PrinterStatusInfo import com.epson.epos2.Epos2PtrReceiveDelegateProtocol -import com.epson.epos2.Epos2Printer -import kotlinx.cinterop.ExperimentalForeignApi 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 @@ -53,42 +53,42 @@ internal actual class EpsonPrintService( @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-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-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-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-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 + "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 + "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 + "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 @@ -97,35 +97,35 @@ internal actual class EpsonPrintService( // SDK 2.23.1 series constants (values match the NS_ENUM order in ePOS2.h) private companion object { - const val EPOS2_TM_M10 = 0 - const val EPOS2_TM_M30 = 1 - const val EPOS2_TM_P20 = 2 - const val EPOS2_TM_P60 = 3 - const val EPOS2_TM_P60II = 4 - const val EPOS2_TM_P80 = 5 - const val EPOS2_TM_T20 = 6 - const val EPOS2_TM_T60 = 7 - const val EPOS2_TM_T70 = 8 - const val EPOS2_TM_T81 = 9 - const val EPOS2_TM_T82 = 10 - const val EPOS2_TM_T83 = 11 - const val EPOS2_TM_T88 = 12 - const val EPOS2_TM_T90 = 13 - const val EPOS2_TM_T90KP = 14 - const val EPOS2_TM_U220 = 15 - const val EPOS2_TM_U330 = 16 - const val EPOS2_TM_L90 = 17 - const val EPOS2_TM_H6000 = 18 + const val EPOS2_TM_M10 = 0 + const val EPOS2_TM_M30 = 1 + const val EPOS2_TM_P20 = 2 + const val EPOS2_TM_P60 = 3 + const val EPOS2_TM_P60II = 4 + const val EPOS2_TM_P80 = 5 + const val EPOS2_TM_T20 = 6 + const val EPOS2_TM_T60 = 7 + const val EPOS2_TM_T70 = 8 + const val EPOS2_TM_T81 = 9 + const val EPOS2_TM_T82 = 10 + const val EPOS2_TM_T83 = 11 + const val EPOS2_TM_T88 = 12 + const val EPOS2_TM_T90 = 13 + const val EPOS2_TM_T90KP = 14 + const val EPOS2_TM_U220 = 15 + const val EPOS2_TM_U330 = 16 + const val EPOS2_TM_L90 = 17 + const val EPOS2_TM_H6000 = 18 const val EPOS2_TM_T83III = 19 - const val EPOS2_TM_T100 = 20 - const val EPOS2_TM_M30II = 21 - const val EPOS2_TS_100 = 22 - const val EPOS2_TM_M50 = 23 + const val EPOS2_TM_T100 = 20 + const val EPOS2_TM_M30II = 21 + const val EPOS2_TS_100 = 22 + const val EPOS2_TM_M50 = 23 const val EPOS2_TM_T88VII = 24 const val EPOS2_TM_L90LFC = 25 - const val EPOS2_TM_L100 = 26 - const val EPOS2_TM_P20II = 27 - const val EPOS2_TM_P80II = 28 + const val EPOS2_TM_L100 = 26 + const val EPOS2_TM_P20II = 27 + const val EPOS2_TM_P80II = 28 const val EPOS2_TM_M30III = 29 const val EPOS2_MODEL_ANK = 0 } 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 index 8a47529..25fffea 100644 --- 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 @@ -55,12 +55,11 @@ internal object EpsonPrinterErrorState { 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 - } + 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 @@ -76,20 +75,20 @@ internal object EpsonPrinterErrorState { 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 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 - } + 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 + } } From 5a6c4baee99a35e24e897992050f6768b52f4b36 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:14:42 +0100 Subject: [PATCH 11/16] Fix formatting violations in EpsonPrinterDiscovery.ios.kt Co-Authored-By: Claude Opus 4.6 (1M context) --- .../printengine/epson/EpsonPrinterDiscovery.ios.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 6940266..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 @@ -4,7 +4,6 @@ import com.epson.epos2.Epos2DeviceInfo import com.epson.epos2.Epos2Discovery import com.epson.epos2.Epos2DiscoveryDelegateProtocol import com.epson.epos2.Epos2FilterOption -import kotlinx.cinterop.ExperimentalForeignApi import de.tillhub.printengine.data.ConnectionType import de.tillhub.printengine.data.DiscoveryState import de.tillhub.printengine.data.ExternalPrinter @@ -13,6 +12,7 @@ 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 @@ -26,6 +26,7 @@ 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 @@ -94,10 +95,8 @@ actual class EpsonPrinterDiscovery : PrinterDiscovery { ) } - private fun String.toConnectionType(): ConnectionType = - ConnectionType.entries.find { it.value == this } - ?: ConnectionType.LAN + private fun String.toConnectionType(): ConnectionType = ConnectionType.entries.find { it.value == this } + ?: ConnectionType.LAN - private fun Epos2DeviceInfo.isValid(): Boolean = - deviceType == EPOS2_TYPE_PRINTER && !deviceName.isNullOrEmpty() + private fun Epos2DeviceInfo.isValid(): Boolean = deviceType == EPOS2_TYPE_PRINTER && !deviceName.isNullOrEmpty() } From 74667cdb173b70512befd19cdb8c20d9666ec4bb Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:52:27 +0100 Subject: [PATCH 12/16] [UNTIL-19417] Fix Epson iOS cinterop commonization and use SDK constants directly Enable cinterop commonization so Epson SDK constants (enums, #defines) are available in the shared iosMain source set. Replace ~60 hardcoded constants with direct imports from com.epson.epos2. Add a UIKit shim header that imports only UIImage.h instead of all of UIKit, preventing the commonizer from crashing on platform-specific UIKit types (UIMenuSystemElementGroupPreference) that differ across iOS targets. Co-Authored-By: Claude Opus 4.6 (1M context) --- gradle.properties | 1 + print-engine/build.gradle.kts | 2 +- print-plugins/epson/build.gradle.kts | 6 +- .../nativeInterop/cinterop/shim/UIKit/UIKit.h | 16 +++++ .../epson/EpsonPrintService.ios.kt | 66 +++++++++---------- .../epson/EpsonPrinterController.ios.kt | 33 +++++----- .../epson/EpsonPrinterErrorState.ios.kt | 65 ++++++++---------- print-plugins/pax/build.gradle.kts | 3 + print-plugins/star/build.gradle.kts | 1 + print-plugins/sunmi/build.gradle.kts | 3 + print-plugins/verifone/build.gradle.kts | 3 + 11 files changed, 105 insertions(+), 94 deletions(-) create mode 100644 print-plugins/epson/nativeInterop/cinterop/shim/UIKit/UIKit.h 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/print-engine/build.gradle.kts b/print-engine/build.gradle.kts index 4163614..9c641fa 100644 --- a/print-engine/build.gradle.kts +++ b/print-engine/build.gradle.kts @@ -47,7 +47,7 @@ kotlin { implementation(libs.kotlin.coroutines) // Compose UI for cross-platform ImageBitmap - api(compose.ui) + implementation(compose.ui) // Utils implementation(libs.kermit) diff --git a/print-plugins/epson/build.gradle.kts b/print-plugins/epson/build.gradle.kts index aeb6bbd..9a1a7e1 100644 --- a/print-plugins/epson/build.gradle.kts +++ b/print-plugins/epson/build.gradle.kts @@ -105,7 +105,10 @@ kotlin { compilations["main"].cinterops { create("epos2") { definitionFile = file("nativeInterop/cinterop/epos2.def") - includeDirs("$epsonXcframeworkDir/$slice/Headers") + includeDirs( + file("nativeInterop/cinterop/shim"), + "$epsonXcframeworkDir/$slice/Headers", + ) } } binaries.all { @@ -130,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/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/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt b/print-plugins/epson/src/iosMain/kotlin/de/tillhub/printengine/epson/EpsonPrintService.ios.kt index 6e7f859..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,6 +1,37 @@ 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 @@ -94,39 +125,4 @@ internal actual class EpsonPrintService( EPOS2_TM_T88 } } - - // SDK 2.23.1 series constants (values match the NS_ENUM order in ePOS2.h) - private companion object { - const val EPOS2_TM_M10 = 0 - const val EPOS2_TM_M30 = 1 - const val EPOS2_TM_P20 = 2 - const val EPOS2_TM_P60 = 3 - const val EPOS2_TM_P60II = 4 - const val EPOS2_TM_P80 = 5 - const val EPOS2_TM_T20 = 6 - const val EPOS2_TM_T60 = 7 - const val EPOS2_TM_T70 = 8 - const val EPOS2_TM_T81 = 9 - const val EPOS2_TM_T82 = 10 - const val EPOS2_TM_T83 = 11 - const val EPOS2_TM_T88 = 12 - const val EPOS2_TM_T90 = 13 - const val EPOS2_TM_T90KP = 14 - const val EPOS2_TM_U220 = 15 - const val EPOS2_TM_U330 = 16 - const val EPOS2_TM_L90 = 17 - const val EPOS2_TM_H6000 = 18 - const val EPOS2_TM_T83III = 19 - const val EPOS2_TM_T100 = 20 - const val EPOS2_TM_M30II = 21 - const val EPOS2_TS_100 = 22 - const val EPOS2_TM_M50 = 23 - const val EPOS2_TM_T88VII = 24 - const val EPOS2_TM_L90LFC = 25 - const val EPOS2_TM_L100 = 26 - const val EPOS2_TM_P20II = 27 - const val EPOS2_TM_P80II = 28 - const val EPOS2_TM_M30III = 29 - const val EPOS2_MODEL_ANK = 0 - } } 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 c37494f..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,6 +1,21 @@ 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.ExternalPrinter @@ -181,8 +196,6 @@ internal actual class EpsonPrinterController( } private companion object { - const val EPOS2_SUCCESS = 0 - const val QR_DIMENSION = 240 const val BARCODE_HEIGHT = 100 const val BARCODE_MODULE_WIDTH = 3 @@ -190,22 +203,6 @@ internal actual class EpsonPrinterController( const val THREE_FEED_LINES = 3 const val IMAGE_BRIGHTNESS = 1.0 const val IMAGE_START_XY = 0 - - // ePOS2.h enum values - const val EPOS2_TRUE = 1 - const val EPOS2_PARAM_DEFAULT = -2 - const val EPOS2_PARAM_UNSPECIFIED = -1 - const val EPOS2_ALIGN_CENTER = 1 - const val EPOS2_FONT_A = 0 - const val EPOS2_COLOR_1 = 1 - const val EPOS2_MODE_MONO = 0 - const val EPOS2_HALFTONE_DITHER = 0 - const val EPOS2_COMPRESS_AUTO = 2 - const val EPOS2_BARCODE_CODE128 = 10 - const val EPOS2_HRI_BELOW = 2 - const val EPOS2_SYMBOL_QRCODE_MODEL_1 = 2 - const val EPOS2_LEVEL_M = 10 - const val EPOS2_CUT_NO_FEED = 1 } } 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 index 25fffea..615fcb3 100644 --- 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 @@ -1,51 +1,38 @@ 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 { - // ePOS2.h Epos2ErrorStatus enum (sequential from 0) - private const val EPOS2_SUCCESS = 0 - private const val EPOS2_ERR_PARAM = 1 - private const val EPOS2_ERR_CONNECT = 2 - private const val EPOS2_ERR_TIMEOUT = 3 - private const val EPOS2_ERR_MEMORY = 4 - private const val EPOS2_ERR_DISCONNECT = 10 - - // ePOS2.h Epos2CallbackCode enum (sequential from 0) - private const val EPOS2_CODE_SUCCESS = 0 - private const val EPOS2_CODE_PRINTING = 13 - - // ePOS2.h Epos2PrinterError enum - private const val EPOS2_NO_ERR = 0 - private const val EPOS2_MECHANICAL_ERR = 1 - private const val EPOS2_AUTOCUTTER_ERR = 2 - private const val EPOS2_UNRECOVER_ERR = 3 - private const val EPOS2_AUTORECOVER_ERR = 4 - - // ePOS2.h status constants - private const val EPOS2_FALSE = 0 - private const val EPOS2_TRUE = 1 - private const val EPOS2_PAPER_EMPTY = 2 // EPOS2_PAPER_OK=0, NEAR_END=1, EMPTY=2 - private const val EPOS2_BATTERY_LEVEL_0 = 0 - - // ePOS2.h Epos2AutoRecoverError enum - private const val EPOS2_HEAD_OVERHEAT = 0 - private const val EPOS2_MOTOR_OVERHEAT = 1 - private const val EPOS2_BATTERY_OVERHEAT = 2 - private const val EPOS2_WRONG_PAPER = 3 - private const val EPOS2_COVER_OPEN = 4 - - // ePOS2.h Epos2UnrecoverError enum - private const val EPOS2_HIGH_VOLTAGE_ERR = 0 - private const val EPOS2_LOW_VOLTAGE_ERR = 1 - - // ePOS2.h defines: #define EPOS2_UNKNOWN (-3) - private const val EPOS2_UNKNOWN = -3 - fun epsonErrorStatusToState(errorCode: Int): PrinterState = when (errorCode) { EPOS2_ERR_PARAM -> PrinterState.Error.Epson.DataError EPOS2_ERR_CONNECT -> PrinterState.Error.NotAvailable 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/star/build.gradle.kts b/print-plugins/star/build.gradle.kts index da07327..a1a989e 100644 --- a/print-plugins/star/build.gradle.kts +++ b/print-plugins/star/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { implementation(project(":print-engine")) implementation(libs.kotlin.coroutines) + implementation(compose.ui) // Utils implementation(libs.kermit) 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/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 From ff20dda2145d4da4200097f2d4d2e0b883970005 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:44:21 +0100 Subject: [PATCH 13/16] [UNTIL-19417] Update README with KMP, Maven Central, and iOS info Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 94 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9141e13..621860d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,55 @@ [![](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 +### Maven Central (recommended) + +**Step 1.** Add Maven Central to your repository list: + +```kotlin +// settings.gradle.kts +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} +``` + +**Step 2.** Add the core dependency and the plugins you need: + +```kotlin +// build.gradle.kts +dependencies { + // 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") +} +``` + +### JitPack (legacy) + **Step 1.** Add the JitPack repository to your `settings.gradle` file: ```groovy @@ -27,6 +70,8 @@ dependencies { } ``` +### Android-specific setup + **Step 3.** Enable JNI legacy packaging ```groovy @@ -39,11 +84,13 @@ packaging { # 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. @@ -213,6 +260,7 @@ override fun onCreate(savedInstanceState: Bundle?) { ### Star Printer Library A library for integrating Star Micronics printers using the StarXpand SDK. +Supports Android and iOS. #### Installation @@ -270,7 +318,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 +330,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 +339,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 @@ -306,6 +354,7 @@ Add to your `build.gradle` ### Epson Printer Library A library for integrating Epson printers using the ePOS2 lib. +Supports Android and iOS. #### Installation @@ -317,7 +366,7 @@ Add to your `build.gradle` #### 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 +412,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 +430,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 +452,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 +464,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) From 2994fbb78474e71275f9cf4eb23a6171c0ac28b9 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:55:53 +0100 Subject: [PATCH 14/16] [UNTIL-19417] Add Compose UI setup, fix plugin coordinates in README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 621860d..3a2570d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,23 @@ dependencies { } ``` +### Compose UI (required for image printing) + +If you use `PrintCommand.Image`, you need to add Compose UI to your project since `ImageBitmap` is +part of the API: + +```kotlin +// build.gradle.kts +plugins { + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose") +} + +dependencies { + implementation(compose.ui) +} +``` + ### Android-specific setup **Step 3.** Enable JNI legacy packaging @@ -82,6 +99,9 @@ packaging { } ``` +> **Note:** ProGuard/R8 consumer rules are applied automatically — no manual ProGuard configuration +> is needed. + # Usage This SDK offers supports the following printing devices: @@ -264,10 +284,10 @@ Supports Android and iOS. #### Installation -Add to your `build.gradle` +See [Maven Central setup](#maven-central-recommended) above for the correct coordinates, or via JitPack: ```gradle - implementation 'com.tillhub.printengine:star-printer:1.0.0' + implementation 'com.github.tillhub:print-engine:x.x.x' ``` #### Setup & Usage @@ -356,12 +376,14 @@ Add to your `build.gradle` A library for integrating Epson printers using the ePOS2 lib. Supports Android and iOS. +> **Note:** On iOS, the Epson ePOS2 SDK (~68 MB) is automatically downloaded on the first build. + #### Installation -Add to your `build.gradle` +See [Maven Central setup](#maven-central-recommended) above for the correct coordinates, or via JitPack: ```gradle - implementation 'com.tillhub.printengine:epson-printer:1.0.0' + implementation 'com.github.tillhub:print-engine:x.x.x' ``` #### Setup & Usage From 37697f70347f1a4359ee5fd02c48f77c16926f00 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:02:09 +0100 Subject: [PATCH 15/16] =?UTF-8?q?[UNTIL-19417]=20Clean=20up=20README=20?= =?UTF-8?q?=E2=80=94=20remove=20legacy=20JitPack,=20use=20Maven=20Central?= =?UTF-8?q?=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 65 +++++++++++++++---------------------------------------- 1 file changed, 17 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 3a2570d..bd93bd5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -[![](https://jitpack.io/v/tillhub/print-engine.svg)](https://jitpack.io/#tillhub/print-engine) [![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) @@ -16,8 +15,6 @@ Built as a Kotlin Multiplatform (KMP) library, it supports both Android and iOS # How to setup -### Maven Central (recommended) - **Step 1.** Add Maven Central to your repository list: ```kotlin @@ -48,32 +45,7 @@ dependencies { } ``` -### JitPack (legacy) - -**Step 1.** Add the JitPack repository to your `settings.gradle` file: - -```groovy -dependencyResolutionManagement { - repositories { - ... - mavenCentral() - maven { url 'https://jitpack.io' } - } -} -``` - -**Step 2.** Add the dependency to your app `build.gradle`: - -```groovy -dependencies { - implementation 'com.github.tillhub:print-engine:x.x.x' -} -``` - -### Compose UI (required for image printing) - -If you use `PrintCommand.Image`, you need to add Compose UI to your project since `ImageBitmap` is -part of the API: +**Step 3.** If you use `PrintCommand.Image`, add Compose UI since `ImageBitmap` is part of the API: ```kotlin // build.gradle.kts @@ -87,20 +59,20 @@ dependencies { } ``` -### Android-specific setup - -**Step 3.** Enable JNI legacy packaging +**Step 4.** Enable JNI legacy packaging (Android only): -```groovy -packaging { - jniLibs { - useLegacyPackaging = true +```kotlin +// build.gradle.kts +android { + packaging { + jniLibs { + useLegacyPackaging = true + } } } ``` -> **Note:** ProGuard/R8 consumer rules are applied automatically — no manual ProGuard configuration -> is needed. +> **Note:** ProGuard/R8 consumer rules are applied automatically — no manual configuration needed. # Usage @@ -284,11 +256,9 @@ Supports Android and iOS. #### Installation -See [Maven Central setup](#maven-central-recommended) above for the correct coordinates, or via JitPack: - - ```gradle - implementation 'com.github.tillhub:print-engine:x.x.x' - ``` +```kotlin +implementation("io.github.tillhub:print-engine-star:3.0.5") +``` #### Setup & Usage @@ -371,6 +341,7 @@ See [Maven Central setup](#maven-central-recommended) above for the correct coor printerEngine.initPrinter(service) printerEngine.printer.startPrintJob(printJob) ``` + ### Epson Printer Library A library for integrating Epson printers using the ePOS2 lib. @@ -380,11 +351,9 @@ Supports Android and iOS. #### Installation -See [Maven Central setup](#maven-central-recommended) above for the correct coordinates, or via JitPack: - - ```gradle - implementation 'com.github.tillhub:print-engine:x.x.x' - ``` +```kotlin +implementation("io.github.tillhub:print-engine-epson:3.0.5") +``` #### Setup & Usage From 0c6a9edb5dfaf364339c1a56bca50398c28e8db7 Mon Sep 17 00:00:00 2001 From: shekar-allam <89923464+shekar-allam@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:04:48 +0100 Subject: [PATCH 16/16] [UNTIL-19417] Remove ProGuard note from README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index bd93bd5..01e24c8 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,6 @@ android { } ``` -> **Note:** ProGuard/R8 consumer rules are applied automatically — no manual configuration needed. - # Usage This SDK offers supports the following printing devices: