@delicity/capacitor-thermal-printer 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/DelicityThermalPrinter.podspec +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +649 -0
  4. package/android/build.gradle +122 -0
  5. package/android/src/main/AndroidManifest.xml +38 -0
  6. package/android/src/main/java/com/delicity/thermalprinter/Logger.kt +50 -0
  7. package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterEngine.kt +528 -0
  8. package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterPlugin.kt +334 -0
  9. package/android/src/main/java/com/delicity/thermalprinter/adapters/BleAdapter.kt +125 -0
  10. package/android/src/main/java/com/delicity/thermalprinter/adapters/BrotherAdapter.kt +206 -0
  11. package/android/src/main/java/com/delicity/thermalprinter/adapters/EpsonAdapter.kt +384 -0
  12. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosAdapter.kt +160 -0
  13. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosCommands.kt +42 -0
  14. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosTextEncoder.kt +138 -0
  15. package/android/src/main/java/com/delicity/thermalprinter/adapters/PrinterAdapter.kt +95 -0
  16. package/android/src/main/java/com/delicity/thermalprinter/adapters/RawTcpAdapter.kt +96 -0
  17. package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkContract.kt +158 -0
  18. package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkReflect.kt +104 -0
  19. package/android/src/main/java/com/delicity/thermalprinter/adapters/StarAdapter.kt +322 -0
  20. package/android/src/main/java/com/delicity/thermalprinter/adapters/UsbAdapter.kt +248 -0
  21. package/android/src/main/java/com/delicity/thermalprinter/adapters/ZebraAdapter.kt +207 -0
  22. package/android/src/main/java/com/delicity/thermalprinter/discovery/AdapterPriority.kt +39 -0
  23. package/android/src/main/java/com/delicity/thermalprinter/discovery/BleScanner.kt +70 -0
  24. package/android/src/main/java/com/delicity/thermalprinter/discovery/BluetoothClassicScanner.kt +112 -0
  25. package/android/src/main/java/com/delicity/thermalprinter/discovery/DiscoveryManager.kt +136 -0
  26. package/android/src/main/java/com/delicity/thermalprinter/discovery/TcpScanner.kt +96 -0
  27. package/android/src/main/java/com/delicity/thermalprinter/image/ImageCache.kt +88 -0
  28. package/android/src/main/java/com/delicity/thermalprinter/image/ImageProcessor.kt +220 -0
  29. package/android/src/main/java/com/delicity/thermalprinter/image/TextRasterizer.kt +99 -0
  30. package/android/src/main/java/com/delicity/thermalprinter/model/Models.kt +206 -0
  31. package/android/src/main/java/com/delicity/thermalprinter/model/PrintItem.kt +100 -0
  32. package/android/src/main/java/com/delicity/thermalprinter/store/PrinterStore.kt +71 -0
  33. package/android/src/main/java/com/delicity/thermalprinter/transport/BleGattClient.kt +201 -0
  34. package/android/src/main/java/com/delicity/thermalprinter/transport/BluetoothSppTransport.kt +110 -0
  35. package/android/src/main/java/com/delicity/thermalprinter/transport/ByteTransport.kt +18 -0
  36. package/android/src/main/java/com/delicity/thermalprinter/transport/TcpTransport.kt +83 -0
  37. package/dist/esm/adapters/dedup.d.ts +26 -0
  38. package/dist/esm/adapters/dedup.js +66 -0
  39. package/dist/esm/adapters/dedup.js.map +1 -0
  40. package/dist/esm/adapters/priority.d.ts +29 -0
  41. package/dist/esm/adapters/priority.js +55 -0
  42. package/dist/esm/adapters/priority.js.map +1 -0
  43. package/dist/esm/core/enums.d.ts +61 -0
  44. package/dist/esm/core/enums.js +25 -0
  45. package/dist/esm/core/enums.js.map +1 -0
  46. package/dist/esm/core/errors.d.ts +16 -0
  47. package/dist/esm/core/errors.js +53 -0
  48. package/dist/esm/core/errors.js.map +1 -0
  49. package/dist/esm/core/escpos-text.d.ts +33 -0
  50. package/dist/esm/core/escpos-text.js +239 -0
  51. package/dist/esm/core/escpos-text.js.map +1 -0
  52. package/dist/esm/core/imaging.d.ts +91 -0
  53. package/dist/esm/core/imaging.js +184 -0
  54. package/dist/esm/core/imaging.js.map +1 -0
  55. package/dist/esm/core/models.d.ts +131 -0
  56. package/dist/esm/core/models.js +2 -0
  57. package/dist/esm/core/models.js.map +1 -0
  58. package/dist/esm/core/options.d.ts +154 -0
  59. package/dist/esm/core/options.js +2 -0
  60. package/dist/esm/core/options.js.map +1 -0
  61. package/dist/esm/core/text.d.ts +138 -0
  62. package/dist/esm/core/text.js +14 -0
  63. package/dist/esm/core/text.js.map +1 -0
  64. package/dist/esm/definitions.d.ts +155 -0
  65. package/dist/esm/definitions.js +2 -0
  66. package/dist/esm/definitions.js.map +1 -0
  67. package/dist/esm/index.d.ts +15 -0
  68. package/dist/esm/index.js +18 -0
  69. package/dist/esm/index.js.map +1 -0
  70. package/dist/esm/web.d.ts +63 -0
  71. package/dist/esm/web.js +112 -0
  72. package/dist/esm/web.js.map +1 -0
  73. package/dist/plugin.cjs.js +224 -0
  74. package/dist/plugin.cjs.js.map +1 -0
  75. package/dist/plugin.js +227 -0
  76. package/dist/plugin.js.map +1 -0
  77. package/ios/Plugin/Adapters/BrotherAdapter.swift +139 -0
  78. package/ios/Plugin/Adapters/EpsonAdapter.swift +131 -0
  79. package/ios/Plugin/Adapters/EscPosAdapter.swift +106 -0
  80. package/ios/Plugin/Adapters/EscPosCommands.swift +32 -0
  81. package/ios/Plugin/Adapters/EscPosTextEncoder.swift +115 -0
  82. package/ios/Plugin/Adapters/PrinterAdapter.swift +44 -0
  83. package/ios/Plugin/Adapters/RawTcpAdapter.swift +70 -0
  84. package/ios/Plugin/Adapters/StarAdapter.swift +305 -0
  85. package/ios/Plugin/Adapters/ZebraAdapter.swift +119 -0
  86. package/ios/Plugin/Discovery/AdapterPriority.swift +21 -0
  87. package/ios/Plugin/Discovery/BonjourScanner.swift +51 -0
  88. package/ios/Plugin/Discovery/DiscoveryManager.swift +86 -0
  89. package/ios/Plugin/Image/ImageCache.swift +73 -0
  90. package/ios/Plugin/Image/ImageProcessor.swift +168 -0
  91. package/ios/Plugin/Image/TextRasterizer.swift +81 -0
  92. package/ios/Plugin/Logger.swift +33 -0
  93. package/ios/Plugin/Model/Models.swift +174 -0
  94. package/ios/Plugin/Model/PrintItem.swift +111 -0
  95. package/ios/Plugin/Store/PrinterStore.swift +51 -0
  96. package/ios/Plugin/ThermalPrinterEngine.swift +395 -0
  97. package/ios/Plugin/ThermalPrinterPlugin.m +22 -0
  98. package/ios/Plugin/ThermalPrinterPlugin.swift +258 -0
  99. package/ios/Plugin/Transport/TcpTransport.swift +89 -0
  100. package/package.json +96 -0
@@ -0,0 +1,207 @@
1
+ package com.delicity.thermalprinter.adapters
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import com.delicity.thermalprinter.model.AdapterId
6
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
7
+ import com.delicity.thermalprinter.model.ErrorCode
8
+ import com.delicity.thermalprinter.model.PrinterException
9
+ import com.delicity.thermalprinter.model.PrinterProfile
10
+ import com.delicity.thermalprinter.model.PrinterStatus
11
+ import com.delicity.thermalprinter.model.RenderOptions
12
+ import com.delicity.thermalprinter.model.Transport
13
+ import java.util.concurrent.ConcurrentHashMap
14
+
15
+ /**
16
+ * Adapter Zebra basé sur le SDK Link-OS (`com.zebra.sdk`), piloté par RÉFLEXION.
17
+ *
18
+ * ⚠️ Zebra N'EST PAS de l'ESC/POS : le langage est ZPL/CPCL. Le SDK convertit le
19
+ * Bitmap en ZPL et l'imprime via GraphicsUtil.printImage(...). On ne route JAMAIS
20
+ * une Zebra vers EscPosAdapter (priority.ts attribue un score négatif).
21
+ *
22
+ * Le SDK Link-OS n'est pas redistribuable (licence Zebra) : déposer
23
+ * `ZSDK_ANDROID_API.jar` (portail Zebra) — ou activer le dépôt Maven privé Zebra
24
+ * (credentials). Voir docs/SDK_INTEGRATION.md (§ Zebra).
25
+ */
26
+ class ZebraAdapter(private val context: Context) : PrinterAdapter {
27
+
28
+ override val id = AdapterId.ZEBRA
29
+
30
+ private val cache = ConcurrentHashMap<String, Any>() // printerId -> com.zebra.sdk.comm.Connection
31
+
32
+ override fun isAvailable(): Boolean = EpsonAdapter.classExists(CONNECTION)
33
+
34
+ // -------------------------------------------------------------------------
35
+ // Découverte (NetworkDiscoverer / BluetoothDiscoverer + DiscoveryHandler proxy)
36
+ // -------------------------------------------------------------------------
37
+
38
+ override suspend fun discover(timeoutMs: Long, onFound: (DiscoveredPrinter) -> Unit) {
39
+ if (!isAvailable()) return
40
+ val handler = SdkReflect.proxy(DISCOVERY_HANDLER, mapOf(
41
+ "foundPrinter" to { args ->
42
+ val dp = args.getOrNull(0)
43
+ if (dp != null) {
44
+ val address = SdkReflect.call(dp, "getAddress") as? String
45
+ ?: SdkReflect.field(dp, "address") as? String ?: ""
46
+ if (address.isNotEmpty()) {
47
+ onFound(
48
+ DiscoveredPrinter(
49
+ id = "zebra:$address",
50
+ name = "Zebra $address",
51
+ brand = "Zebra",
52
+ transport = if (address.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) Transport.BLUETOOTH else Transport.WIFI,
53
+ adapter = AdapterId.ZEBRA,
54
+ address = address,
55
+ discoveredBy = mutableSetOf(AdapterId.ZEBRA),
56
+ ),
57
+ )
58
+ }
59
+ }
60
+ null
61
+ },
62
+ "discoveryFinished" to { null },
63
+ "discoveryError" to { null },
64
+ ))
65
+ // NetworkDiscoverer.findPrinters(DiscoveryHandler) — bloquant jusqu'à fin.
66
+ runCatching {
67
+ SdkReflect.callStatic(
68
+ NETWORK_DISCOVERER, "findPrinters",
69
+ arrayOf(SdkReflect.classOrNull(DISCOVERY_HANDLER)!!),
70
+ arrayOf(handler),
71
+ )
72
+ }
73
+ // BluetoothDiscoverer.findPrinters(Context, DiscoveryHandler)
74
+ runCatching {
75
+ SdkReflect.callStatic(
76
+ BLUETOOTH_DISCOVERER, "findPrinters",
77
+ arrayOf(Context::class.java, SdkReflect.classOrNull(DISCOVERY_HANDLER)!!),
78
+ arrayOf(context, handler),
79
+ )
80
+ }
81
+ }
82
+
83
+ override fun canHandle(profile: PrinterProfile): Boolean =
84
+ isAvailable() && profile.adapter == AdapterId.ZEBRA
85
+
86
+ // -------------------------------------------------------------------------
87
+ // Connexion
88
+ // -------------------------------------------------------------------------
89
+
90
+ override suspend fun connect(profile: PrinterProfile, timeoutMs: Long) {
91
+ ensureSdk()
92
+ if (isConnected(profile.id)) return
93
+ val connection = buildConnection(profile)
94
+ try {
95
+ SdkReflect.call(connection, "open")
96
+ } catch (e: Throwable) {
97
+ throw PrinterException(ErrorCode.CONNECTION_FAILED, "Connexion Zebra échouée: ${profile.address}", e.message, retryable = true)
98
+ }
99
+ cache[profile.id] = connection
100
+ }
101
+
102
+ override fun isConnected(printerId: String): Boolean {
103
+ val c = cache[printerId] ?: return false
104
+ return (SdkReflect.call(c, "isConnected") as? Boolean) ?: true
105
+ }
106
+
107
+ override suspend fun disconnect(printerId: String) {
108
+ cache.remove(printerId)?.let { runCatching { SdkReflect.call(it, "close") } }
109
+ }
110
+
111
+ // -------------------------------------------------------------------------
112
+ // Impression image (-> ZPL via GraphicsUtil)
113
+ // -------------------------------------------------------------------------
114
+
115
+ override suspend fun printBitmap(profile: PrinterProfile, bitmap: Bitmap, options: RenderOptions): Int {
116
+ val connection = cache[profile.id]
117
+ ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "Zebra non connecté: ${profile.id}")
118
+ try {
119
+ val printer = SdkReflect.callStatic(
120
+ PRINTER_FACTORY, "getInstance",
121
+ arrayOf(SdkReflect.classOrNull(CONNECTION)!!), arrayOf(connection),
122
+ ) ?: error("ZebraPrinterFactory.getInstance null")
123
+ val graphics = SdkReflect.call(printer, "getGraphicsUtil") ?: error("getGraphicsUtil null")
124
+ val zebraImage = SdkReflect.callStatic(
125
+ IMAGE_FACTORY, "getImage",
126
+ arrayOf(Bitmap::class.java), arrayOf(bitmap),
127
+ ) ?: error("ZebraImageFactory.getImage null")
128
+ val intT = Int::class.javaPrimitiveType!!
129
+ val boolT = Boolean::class.javaPrimitiveType!!
130
+ repeat(options.copies.coerceAtLeast(1)) {
131
+ SdkReflect.call(
132
+ graphics, "printImage",
133
+ arrayOf(SdkReflect.classOrNull(IMAGE_I)!!, intT, intT, intT, intT, boolT),
134
+ arrayOf(zebraImage, 0, 0, bitmap.width, bitmap.height, false),
135
+ )
136
+ }
137
+ } catch (e: Throwable) {
138
+ throw PrinterException(ErrorCode.PRINT_FAILED, "Impression Zebra échouée", e.message, retryable = true)
139
+ }
140
+ return bitmap.width * bitmap.height / 8
141
+ }
142
+
143
+ // -------------------------------------------------------------------------
144
+ // Statut
145
+ // -------------------------------------------------------------------------
146
+
147
+ override suspend fun getStatus(profile: PrinterProfile): PrinterStatus {
148
+ val connection = cache[profile.id]
149
+ ?: return PrinterStatus(profile.id, "disconnected", online = false, paper = "unknown")
150
+ return try {
151
+ val printer = SdkReflect.callStatic(
152
+ PRINTER_FACTORY, "getInstance",
153
+ arrayOf(SdkReflect.classOrNull(CONNECTION)!!), arrayOf(connection),
154
+ ) ?: error("getInstance null")
155
+ val status = SdkReflect.call(printer, "getCurrentStatus") ?: error("getCurrentStatus null")
156
+ val ready = (SdkReflect.field(status, "isReadyToPrint") as? Boolean) ?: false
157
+ val paperOut = (SdkReflect.field(status, "isPaperOut") as? Boolean) ?: false
158
+ val headOpen = (SdkReflect.field(status, "isHeadOpen") as? Boolean) ?: false
159
+ PrinterStatus(
160
+ id = profile.id,
161
+ connection = "connected",
162
+ online = ready,
163
+ paper = if (paperOut) "empty" else "ok",
164
+ coverOpen = headOpen,
165
+ errorCode = if (paperOut) ErrorCode.PAPER_EMPTY else if (headOpen) ErrorCode.COVER_OPEN else null,
166
+ )
167
+ } catch (e: Throwable) {
168
+ PrinterStatus(profile.id, "error", online = false, paper = "unknown", rawStatus = e.message)
169
+ }
170
+ }
171
+
172
+ // -------------------------------------------------------------------------
173
+ // Helpers
174
+ // -------------------------------------------------------------------------
175
+
176
+ private fun buildConnection(profile: PrinterProfile): Any = when (profile.transport) {
177
+ Transport.WIFI, Transport.ETHERNET -> {
178
+ val host = profile.address.substringBefore(":")
179
+ val port = profile.address.substringAfter(":", "9100").toIntOrNull() ?: 9100
180
+ SdkReflect.newInstance(
181
+ TCP_CONNECTION,
182
+ arrayOf(String::class.java, Int::class.javaPrimitiveType!!),
183
+ arrayOf(host, port),
184
+ )
185
+ }
186
+ Transport.BLUETOOTH -> SdkReflect.newInstance(
187
+ BT_CONNECTION, arrayOf(String::class.java), arrayOf(profile.address),
188
+ )
189
+ else -> throw PrinterException(ErrorCode.UNSUPPORTED_TRANSPORT, "Transport Zebra non supporté: ${profile.transport.value}")
190
+ }
191
+
192
+ private fun ensureSdk() {
193
+ if (!isAvailable()) throw PrinterException(ErrorCode.SDK_NOT_AVAILABLE, "SDK Zebra Link-OS absent")
194
+ }
195
+
196
+ companion object {
197
+ private const val CONNECTION = "com.zebra.sdk.comm.Connection"
198
+ private const val TCP_CONNECTION = "com.zebra.sdk.comm.TcpConnection"
199
+ private const val BT_CONNECTION = "com.zebra.sdk.comm.BluetoothConnection"
200
+ private const val PRINTER_FACTORY = "com.zebra.sdk.printer.ZebraPrinterFactory"
201
+ private const val IMAGE_FACTORY = "com.zebra.sdk.graphics.ZebraImageFactory"
202
+ private const val IMAGE_I = "com.zebra.sdk.graphics.ZebraImageI"
203
+ private const val NETWORK_DISCOVERER = "com.zebra.sdk.printer.discovery.NetworkDiscoverer"
204
+ private const val BLUETOOTH_DISCOVERER = "com.zebra.sdk.printer.discovery.BluetoothDiscoverer"
205
+ private const val DISCOVERY_HANDLER = "com.zebra.sdk.printer.discovery.DiscoveryHandler"
206
+ }
207
+ }
@@ -0,0 +1,39 @@
1
+ package com.delicity.thermalprinter.discovery
2
+
3
+ import com.delicity.thermalprinter.model.AdapterId
4
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
5
+ import com.delicity.thermalprinter.model.Transport
6
+
7
+ /**
8
+ * Moteur de priorité d'adapter (miroir Kotlin de src/adapters/priority.ts).
9
+ *
10
+ * Règles :
11
+ * 1. SDK officiel reconnaissant l'imprimante -> priorité max.
12
+ * 2. Zebra -> ZebraAdapter UNIQUEMENT (escpos/rawTcp bannis).
13
+ * 3. ESC/POS confirmé > BLE > rawTcp.
14
+ */
15
+ object AdapterPriority {
16
+
17
+ fun score(p: DiscoveredPrinter): Int {
18
+ val brand = p.brand?.lowercase() ?: ""
19
+ val isZebra = brand.contains("zebra") || p.adapter == AdapterId.ZEBRA
20
+ if (isZebra) return if (p.adapter == AdapterId.ZEBRA) 1000 else -1000
21
+
22
+ val fromVendorSdk = p.adapter in setOf(AdapterId.EPSON, AdapterId.STAR, AdapterId.BROTHER)
23
+ if (fromVendorSdk) {
24
+ return when (p.adapter) {
25
+ AdapterId.EPSON -> 900
26
+ AdapterId.STAR -> 890
27
+ AdapterId.BROTHER -> 880
28
+ else -> 850
29
+ }
30
+ }
31
+
32
+ if (p.adapter == AdapterId.ESCPOS) {
33
+ return if (p.transport == Transport.BLUETOOTH) 620 else 600
34
+ }
35
+ if (p.transport == Transport.BLE) return 500
36
+ if (p.adapter == AdapterId.RAW_TCP) return 300
37
+ return 100
38
+ }
39
+ }
@@ -0,0 +1,70 @@
1
+ package com.delicity.thermalprinter.discovery
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.bluetooth.BluetoothAdapter
5
+ import android.bluetooth.le.ScanCallback
6
+ import android.bluetooth.le.ScanFilter
7
+ import android.bluetooth.le.ScanResult
8
+ import android.bluetooth.le.ScanSettings
9
+ import android.content.Context
10
+ import android.os.ParcelUuid
11
+ import com.delicity.thermalprinter.model.AdapterId
12
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
13
+ import com.delicity.thermalprinter.model.Transport
14
+ import com.delicity.thermalprinter.transport.BleGattClient
15
+ import java.util.Collections
16
+ import kotlinx.coroutines.delay
17
+
18
+ /**
19
+ * Scanner BLE : repère les imprimantes thermiques exposant un service "UART série"
20
+ * connu (allowlist [BleGattClient.ADVERTISED_SERVICES]). On filtre sur ces services
21
+ * pour éviter le bruit (la plupart des appareils BLE ne sont pas des imprimantes).
22
+ *
23
+ * Les résultats sont produits avec adapter=ESCPOS / transport=BLE : la connexion +
24
+ * l'écriture sont ensuite gérées par BleAdapter / BleGattClient.
25
+ */
26
+ @SuppressLint("MissingPermission")
27
+ class BleScanner(
28
+ private val context: Context,
29
+ private val btAdapter: BluetoothAdapter,
30
+ ) {
31
+
32
+ suspend fun scan(timeoutMs: Long, onFound: (DiscoveredPrinter) -> Unit) {
33
+ val scanner = btAdapter.bluetoothLeScanner ?: return
34
+ if (!btAdapter.isEnabled) return
35
+
36
+ val seen = Collections.synchronizedSet(mutableSetOf<String>())
37
+ val callback = object : ScanCallback() {
38
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
39
+ val device = result.device ?: return
40
+ val address = device.address ?: return
41
+ if (!seen.add(address)) return
42
+ val name = result.scanRecord?.deviceName ?: runCatching { device.name }.getOrNull() ?: "BLE Printer"
43
+ onFound(
44
+ DiscoveredPrinter(
45
+ id = "ble:$address",
46
+ name = name,
47
+ transport = Transport.BLE,
48
+ adapter = AdapterId.ESCPOS,
49
+ address = address,
50
+ discoveredBy = mutableSetOf(AdapterId.ESCPOS),
51
+ ),
52
+ )
53
+ }
54
+ }
55
+
56
+ val filters = BleGattClient.ADVERTISED_SERVICES.map {
57
+ ScanFilter.Builder().setServiceUuid(ParcelUuid(it)).build()
58
+ }
59
+ val settings = ScanSettings.Builder()
60
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
61
+ .build()
62
+
63
+ try {
64
+ scanner.startScan(filters, settings, callback)
65
+ delay(timeoutMs)
66
+ } finally {
67
+ runCatching { scanner.stopScan(callback) }
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,112 @@
1
+ package com.delicity.thermalprinter.discovery
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.bluetooth.BluetoothAdapter
5
+ import android.bluetooth.BluetoothClass
6
+ import android.bluetooth.BluetoothDevice
7
+ import android.content.BroadcastReceiver
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.content.IntentFilter
11
+ import com.delicity.thermalprinter.model.AdapterId
12
+ import com.delicity.thermalprinter.model.Capabilities
13
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
14
+ import com.delicity.thermalprinter.model.Transport
15
+ import kotlinx.coroutines.delay
16
+
17
+ /**
18
+ * Scanner Bluetooth Classic (Android).
19
+ *
20
+ * Deux sources :
21
+ * 1. Appareils DÉJÀ APPAIRÉS (instantané, sans scan) -> idéal en restauration
22
+ * car l'imprimante est souvent appairée une fois pour toutes.
23
+ * 2. Découverte active (startDiscovery) pour les nouveaux appareils.
24
+ *
25
+ * Tous les résultats sont taggés adapter=ESCPOS (BT classic = SPP générique).
26
+ * Les imprimantes Epson/Star BT seront, elles, mieux remontées par leurs SDK
27
+ * respectifs et prendront la priorité via le dédoublonnage.
28
+ *
29
+ * Permissions requises (API 31+) : BLUETOOTH_SCAN + BLUETOOTH_CONNECT.
30
+ */
31
+ @SuppressLint("MissingPermission")
32
+ class BluetoothClassicScanner(
33
+ private val context: Context,
34
+ private val adapter: BluetoothAdapter?,
35
+ ) {
36
+
37
+ suspend fun scan(
38
+ timeoutMs: Long,
39
+ includePaired: Boolean,
40
+ onFound: (DiscoveredPrinter) -> Unit,
41
+ ) {
42
+ val ad = adapter ?: return
43
+ if (!ad.isEnabled) return
44
+
45
+ // 1) Appareils appairés
46
+ if (includePaired) {
47
+ ad.bondedDevices?.forEach { device ->
48
+ if (looksLikePrinter(device)) onFound(toPrinter(device, paired = true))
49
+ }
50
+ }
51
+
52
+ // 2) Découverte active
53
+ val discovered = mutableSetOf<String>()
54
+ val receiver = object : BroadcastReceiver() {
55
+ override fun onReceive(ctx: Context?, intent: Intent?) {
56
+ if (intent?.action == BluetoothDevice.ACTION_FOUND) {
57
+ val device: BluetoothDevice? =
58
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
59
+ if (device != null && discovered.add(device.address) && looksLikePrinter(device)) {
60
+ onFound(toPrinter(device, paired = false))
61
+ }
62
+ }
63
+ }
64
+ }
65
+ context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND))
66
+ try {
67
+ ad.startDiscovery()
68
+ delay(timeoutMs)
69
+ } finally {
70
+ try { ad.cancelDiscovery() } catch (_: Exception) {}
71
+ try { context.unregisterReceiver(receiver) } catch (_: Exception) {}
72
+ }
73
+ }
74
+
75
+ /** Heuristique : classe IMAGING/printer, ou nom contenant des mots-clés imprimante. */
76
+ private fun looksLikePrinter(device: BluetoothDevice): Boolean {
77
+ val cls = device.bluetoothClass?.majorDeviceClass
78
+ if (cls == BluetoothClass.Device.Major.IMAGING) return true
79
+ val name = (device.name ?: "").lowercase()
80
+ return PRINTER_HINTS.any { name.contains(it) }
81
+ }
82
+
83
+ private fun toPrinter(device: BluetoothDevice, paired: Boolean): DiscoveredPrinter =
84
+ DiscoveredPrinter(
85
+ id = "bluetooth:${device.address}",
86
+ name = device.name ?: device.address,
87
+ brand = guessBrand(device.name),
88
+ transport = Transport.BLUETOOTH,
89
+ adapter = AdapterId.ESCPOS,
90
+ address = device.address,
91
+ capabilities = Capabilities(supportsRasterImage = true, supportsStatus = false),
92
+ discoveredBy = mutableSetOf(AdapterId.ESCPOS),
93
+ ).also { it.isConnected = false; if (paired) it.lastSeenAt = System.currentTimeMillis() }
94
+
95
+ private fun guessBrand(name: String?): String? {
96
+ val n = (name ?: "").lowercase()
97
+ return when {
98
+ n.contains("epson") -> "Epson"
99
+ n.contains("star") -> "Star"
100
+ n.contains("zebra") -> "Zebra"
101
+ n.contains("brother") -> "Brother"
102
+ else -> null
103
+ }
104
+ }
105
+
106
+ companion object {
107
+ private val PRINTER_HINTS = listOf(
108
+ "print", "printer", "pos", "escpos", "esc/pos", "thermal", "receipt",
109
+ "tm-", "mpt", "rpp", "mtp", "bluetooth printer",
110
+ )
111
+ }
112
+ }
@@ -0,0 +1,136 @@
1
+ package com.delicity.thermalprinter.discovery
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.content.Context
5
+ import com.delicity.thermalprinter.adapters.PrinterAdapter
6
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
7
+ import kotlinx.coroutines.async
8
+ import kotlinx.coroutines.awaitAll
9
+ import kotlinx.coroutines.coroutineScope
10
+ import kotlinx.coroutines.withTimeoutOrNull
11
+ import java.util.Collections
12
+
13
+ /**
14
+ * Orchestre la découverte AGRÉGÉE multi-sources en parallèle puis fusionne.
15
+ *
16
+ * Sources lancées (selon options + disponibilité plateforme) :
17
+ * - SDK Epson / Star / Brother / Zebra (via PrinterAdapter.discover)
18
+ * - TcpScanner (réseau 9100)
19
+ * - BluetoothClassicScanner (Android)
20
+ * - BleScanner (optionnel)
21
+ * - UsbAdapter.discover (optionnel)
22
+ *
23
+ * Chaque source pousse ses résultats dans un buffer thread-safe ; `emitPartial`
24
+ * est invoqué au fil de l'eau (event printerFound). À la fin : fusion +
25
+ * dédoublonnage + arbitrage d'adapter via AdapterPriority.
26
+ */
27
+ class DiscoveryManager(
28
+ private val context: Context,
29
+ private val btAdapter: BluetoothAdapter?,
30
+ private val adapters: List<PrinterAdapter>,
31
+ ) {
32
+
33
+ data class Options(
34
+ val sources: Set<String>?, // null = toutes
35
+ val timeoutMs: Long = 8000,
36
+ val includePaired: Boolean = true,
37
+ val networkCidr: String? = null,
38
+ val tcpPorts: List<Int> = listOf(9100),
39
+ )
40
+
41
+ suspend fun discover(
42
+ options: Options,
43
+ emitPartial: (DiscoveredPrinter) -> Unit,
44
+ ): Pair<List<DiscoveredPrinter>, List<String>> = coroutineScope {
45
+ val buffer = Collections.synchronizedList(mutableListOf<DiscoveredPrinter>())
46
+ val failed = Collections.synchronizedList(mutableListOf<String>())
47
+
48
+ // Collecte thread-safe + émission partielle (callback synchrone).
49
+ val collect: (DiscoveredPrinter) -> Unit = { p ->
50
+ buffer.add(p)
51
+ runCatching { emitPartial(p) }
52
+ }
53
+
54
+ fun enabled(src: String) = options.sources == null || options.sources.contains(src)
55
+
56
+ val jobs = mutableListOf<kotlinx.coroutines.Deferred<Unit>>()
57
+
58
+ // --- Sources SDK fabricants (via adapters) ---
59
+ for (adapter in adapters) {
60
+ val discoverySource = when (adapter.id.value) {
61
+ "epson" -> "epson"
62
+ "star" -> "star"
63
+ "brother" -> "brother"
64
+ "zebra" -> "zebra"
65
+ else -> null
66
+ } ?: continue
67
+ if (enabled(discoverySource) && adapter.isAvailable()) {
68
+ jobs += async {
69
+ runCatching {
70
+ withTimeoutOrNull(options.timeoutMs + 1000) {
71
+ adapter.discover(options.timeoutMs, collect)
72
+ }
73
+ }.onFailure { failed.add(discoverySource) }
74
+ Unit
75
+ }
76
+ }
77
+ }
78
+
79
+ // --- Source TCP réseau ---
80
+ if (enabled("tcp")) {
81
+ jobs += async {
82
+ runCatching {
83
+ TcpScanner(context).scan(options.timeoutMs, options.tcpPorts, options.networkCidr, collect)
84
+ }.onFailure { failed.add("tcp") }
85
+ Unit
86
+ }
87
+ }
88
+
89
+ // --- Source Bluetooth classique (Android) ---
90
+ if (enabled("bluetooth") && btAdapter != null) {
91
+ jobs += async {
92
+ runCatching {
93
+ BluetoothClassicScanner(context, btAdapter)
94
+ .scan(options.timeoutMs, options.includePaired, collect)
95
+ }.onFailure { failed.add("bluetooth") }
96
+ Unit
97
+ }
98
+ }
99
+
100
+ // --- Source BLE (allowlist de services "UART série") ---
101
+ if (enabled("ble") && btAdapter != null) {
102
+ jobs += async {
103
+ runCatching {
104
+ BleScanner(context, btAdapter).scan(options.timeoutMs, collect)
105
+ }.onFailure { failed.add("ble") }
106
+ Unit
107
+ }
108
+ }
109
+
110
+ jobs.awaitAll()
111
+
112
+ Pair(merge(buffer.toList()), failed.distinct())
113
+ }
114
+
115
+ /** Fusion + dédoublonnage par id stable, en conservant le meilleur adapter. */
116
+ private fun merge(incoming: List<DiscoveredPrinter>): List<DiscoveredPrinter> {
117
+ val byId = LinkedHashMap<String, DiscoveredPrinter>()
118
+ for (p in incoming) {
119
+ val existing = byId[p.id]
120
+ if (existing == null) {
121
+ byId[p.id] = p
122
+ continue
123
+ }
124
+ val mergedSources = (existing.discoveredBy + p.discoveredBy).toMutableSet()
125
+ val winner = if (AdapterPriority.score(p) > AdapterPriority.score(existing)) p else existing
126
+ winner.discoveredBy.clear()
127
+ winner.discoveredBy.addAll(mergedSources)
128
+ winner.lastSeenAt = maxOf(existing.lastSeenAt, p.lastSeenAt)
129
+ winner.isConnected = existing.isConnected || p.isConnected
130
+ byId[p.id] = winner
131
+ }
132
+ return byId.values.sortedWith(
133
+ compareByDescending<DiscoveredPrinter> { AdapterPriority.score(it) }.thenBy { it.name },
134
+ )
135
+ }
136
+ }
@@ -0,0 +1,96 @@
1
+ package com.delicity.thermalprinter.discovery
2
+
3
+ import android.content.Context
4
+ import android.net.ConnectivityManager
5
+ import android.net.LinkProperties
6
+ import com.delicity.thermalprinter.model.AdapterId
7
+ import com.delicity.thermalprinter.model.Capabilities
8
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
9
+ import com.delicity.thermalprinter.model.Transport
10
+ import kotlinx.coroutines.Dispatchers
11
+ import kotlinx.coroutines.coroutineScope
12
+ import kotlinx.coroutines.launch
13
+ import kotlinx.coroutines.sync.Semaphore
14
+ import kotlinx.coroutines.sync.withPermit
15
+ import kotlinx.coroutines.withContext
16
+ import java.net.InetSocketAddress
17
+ import java.net.Socket
18
+
19
+ /**
20
+ * Scanner réseau : sonde le /24 courant sur les ports d'impression (9100 par défaut)
21
+ * pour détecter des imprimantes RAW.
22
+ *
23
+ * Stratégie :
24
+ * - déduire le préfixe réseau via ConnectivityManager (ou networkCidr fourni),
25
+ * - tester 1..254 en parallèle (semaphore borné) avec un connect TCP court,
26
+ * - un port ouvert = candidat ESC/POS (adapter ESCPOS), faute d'identification SDK.
27
+ *
28
+ * Limites : ne distingue pas la marque. Les SDK (Epson/Star/...) tournent en
29
+ * parallèle et, via le dédoublonnage, prennent la priorité sur ce résultat brut.
30
+ */
31
+ class TcpScanner(private val context: Context) {
32
+
33
+ suspend fun scan(
34
+ timeoutMs: Long,
35
+ ports: List<Int>,
36
+ networkCidr: String?,
37
+ onFound: (DiscoveredPrinter) -> Unit,
38
+ ) = coroutineScope {
39
+ val prefix = networkCidr?.let { cidrToPrefix(it) } ?: detectPrefix() ?: return@coroutineScope
40
+ val portList = if (ports.isEmpty()) listOf(9100) else ports
41
+ val perHostTimeout = 300 // ms : connect court, le scan global est borné par timeoutMs
42
+ val gate = Semaphore(64) // limiter le parallélisme pour ne pas saturer le Wi-Fi
43
+
44
+ val deadline = System.currentTimeMillis() + timeoutMs
45
+ for (i in 1..254) {
46
+ val host = "$prefix$i"
47
+ for (port in portList) {
48
+ if (System.currentTimeMillis() > deadline) return@coroutineScope
49
+ launch(Dispatchers.IO) {
50
+ gate.withPermit {
51
+ if (isOpen(host, port, perHostTimeout)) {
52
+ onFound(
53
+ DiscoveredPrinter(
54
+ id = "wifi:$host",
55
+ name = host,
56
+ transport = Transport.WIFI,
57
+ adapter = AdapterId.ESCPOS, // sera arbitré par la priorité
58
+ address = "$host:$port",
59
+ capabilities = Capabilities(supportsRasterImage = true),
60
+ discoveredBy = mutableSetOf(AdapterId.ESCPOS, AdapterId.RAW_TCP),
61
+ ),
62
+ )
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ private suspend fun isOpen(host: String, port: Int, timeout: Int): Boolean = withContext(Dispatchers.IO) {
71
+ try {
72
+ Socket().use { s ->
73
+ s.connect(InetSocketAddress(host, port), timeout)
74
+ true
75
+ }
76
+ } catch (e: Exception) {
77
+ false
78
+ }
79
+ }
80
+
81
+ /** Renvoie le préfixe "192.168.1." du réseau courant, ou null. */
82
+ private fun detectPrefix(): String? {
83
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
84
+ val active = cm.activeNetwork ?: return null
85
+ val lp: LinkProperties = cm.getLinkProperties(active) ?: return null
86
+ val addr = lp.linkAddresses.firstOrNull { it.address.address.size == 4 } ?: return null
87
+ val ip = addr.address.hostAddress ?: return null
88
+ return ip.substringBeforeLast('.') + "."
89
+ }
90
+
91
+ private fun cidrToPrefix(cidr: String): String? {
92
+ // Support simple /24 : "192.168.1.0/24" -> "192.168.1."
93
+ val ip = cidr.substringBefore('/')
94
+ return if (ip.count { it == '.' } == 3) ip.substringBeforeLast('.') + "." else null
95
+ }
96
+ }