@annadata/capacitor-mqtt-quic 0.1.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 (64) hide show
  1. package/README.md +399 -0
  2. package/android/NGTCP2_BUILD_INSTRUCTIONS.md +319 -0
  3. package/android/build-nghttp3.sh +182 -0
  4. package/android/build-ngtcp2.sh +289 -0
  5. package/android/build-openssl.sh +302 -0
  6. package/android/build.gradle +75 -0
  7. package/android/gradle.properties +3 -0
  8. package/android/proguard-rules.pro +2 -0
  9. package/android/settings.gradle +1 -0
  10. package/android/src/main/AndroidManifest.xml +1 -0
  11. package/android/src/main/assets/mqttquic_ca.pem +5 -0
  12. package/android/src/main/cpp/CMakeLists.txt +157 -0
  13. package/android/src/main/cpp/ngtcp2_jni.cpp +928 -0
  14. package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +232 -0
  15. package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +339 -0
  16. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Properties.kt +250 -0
  17. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Protocol.kt +281 -0
  18. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5ReasonCodes.kt +109 -0
  19. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +249 -0
  20. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTTypes.kt +47 -0
  21. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +184 -0
  22. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +54 -0
  23. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +21 -0
  24. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +37 -0
  25. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +91 -0
  26. package/android/src/test/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocolTest.kt +92 -0
  27. package/dist/esm/definitions.d.ts +66 -0
  28. package/dist/esm/definitions.d.ts.map +1 -0
  29. package/dist/esm/definitions.js +2 -0
  30. package/dist/esm/definitions.js.map +1 -0
  31. package/dist/esm/index.d.ts +5 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +7 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/web.d.ts +28 -0
  36. package/dist/esm/web.d.ts.map +1 -0
  37. package/dist/esm/web.js +183 -0
  38. package/dist/esm/web.js.map +1 -0
  39. package/dist/plugin.cjs.js +217 -0
  40. package/dist/plugin.cjs.js.map +1 -0
  41. package/dist/plugin.js +215 -0
  42. package/dist/plugin.js.map +1 -0
  43. package/ios/MqttQuicPlugin.podspec +34 -0
  44. package/ios/NGTCP2_BUILD_INSTRUCTIONS.md +302 -0
  45. package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +343 -0
  46. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Properties.swift +280 -0
  47. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Protocol.swift +333 -0
  48. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5ReasonCodes.swift +113 -0
  49. package/ios/Sources/MqttQuicPlugin/MQTT/MQTTProtocol.swift +322 -0
  50. package/ios/Sources/MqttQuicPlugin/MQTT/MQTTTypes.swift +54 -0
  51. package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +229 -0
  52. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.h +29 -0
  53. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.mm +865 -0
  54. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Client.swift +262 -0
  55. package/ios/Sources/MqttQuicPlugin/QUIC/QuicClientStub.swift +66 -0
  56. package/ios/Sources/MqttQuicPlugin/QUIC/QuicTypes.swift +23 -0
  57. package/ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem +5 -0
  58. package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +50 -0
  59. package/ios/Sources/MqttQuicPlugin/Transport/StreamTransport.swift +105 -0
  60. package/ios/Tests/MQTTProtocolTests.swift +82 -0
  61. package/ios/build-nghttp3.sh +173 -0
  62. package/ios/build-ngtcp2.sh +278 -0
  63. package/ios/build-openssl.sh +405 -0
  64. package/package.json +63 -0
@@ -0,0 +1,232 @@
1
+ package ai.annadata.mqttquic
2
+
3
+ import ai.annadata.mqttquic.client.MQTTClient
4
+ import ai.annadata.mqttquic.mqtt.MQTT5PropertyType
5
+ import com.getcapacitor.JSObject
6
+ import com.getcapacitor.Plugin
7
+ import com.getcapacitor.PluginCall
8
+ import com.getcapacitor.PluginMethod
9
+ import com.getcapacitor.annotation.CapacitorPlugin
10
+ import android.system.Os
11
+ import kotlinx.coroutines.CoroutineScope
12
+ import kotlinx.coroutines.Dispatchers
13
+ import kotlinx.coroutines.launch
14
+ import java.nio.charset.StandardCharsets
15
+ import java.io.File
16
+ import java.io.IOException
17
+
18
+ /**
19
+ * Capacitor plugin bridge. Phase 3: connect/publish/subscribe call into MQTTClient.
20
+ */
21
+ @CapacitorPlugin(name = "MqttQuic")
22
+ class MqttQuicPlugin : Plugin() {
23
+
24
+ private var client = MQTTClient(MQTTClient.ProtocolVersion.AUTO)
25
+ private val scope = CoroutineScope(Dispatchers.Main)
26
+
27
+ private fun bundledCaFilePath(): String? {
28
+ return try {
29
+ val assetName = "mqttquic_ca.pem"
30
+ val content = context.assets.open(assetName).bufferedReader().use { it.readText() }
31
+ if (!content.contains("BEGIN CERTIFICATE")) {
32
+ return null
33
+ }
34
+ val outFile = File(context.filesDir, assetName)
35
+ outFile.writeText(content)
36
+ outFile.absolutePath
37
+ } catch (_: IOException) {
38
+ null
39
+ }
40
+ }
41
+
42
+ @PluginMethod
43
+ fun connect(call: PluginCall) {
44
+ val host = call.getString("host") ?: ""
45
+ val port = call.getInt("port") ?: 1884
46
+ val clientId = call.getString("clientId") ?: ""
47
+ val username = call.getString("username")
48
+ val password = call.getString("password")
49
+ val cleanSession = call.getBoolean("cleanSession", true)
50
+ val keepalive = call.getInt("keepalive", 60)
51
+ val protocolVersionStr = call.getString("protocolVersion") ?: "auto"
52
+ val sessionExpiryInterval = call.getInt("sessionExpiryInterval")
53
+ val caFile = call.getString("caFile")
54
+ val caPath = call.getString("caPath")
55
+
56
+ val protocolVersion = when (protocolVersionStr) {
57
+ "5.0" -> MQTTClient.ProtocolVersion.V5
58
+ "3.1.1" -> MQTTClient.ProtocolVersion.V311
59
+ else -> MQTTClient.ProtocolVersion.AUTO
60
+ }
61
+
62
+ if (host.isEmpty() || clientId.isEmpty()) {
63
+ call.reject("host and clientId are required")
64
+ return
65
+ }
66
+
67
+ scope.launch {
68
+ try {
69
+ try {
70
+ val bundled = bundledCaFilePath()
71
+ when {
72
+ caFile != null -> Os.setenv("MQTT_QUIC_CA_FILE", caFile, true)
73
+ bundled != null -> Os.setenv("MQTT_QUIC_CA_FILE", bundled, true)
74
+ else -> Os.setenv("MQTT_QUIC_CA_FILE", "", true)
75
+ }
76
+ if (caPath != null) {
77
+ Os.setenv("MQTT_QUIC_CA_PATH", caPath, true)
78
+ } else {
79
+ Os.setenv("MQTT_QUIC_CA_PATH", "", true)
80
+ }
81
+ } catch (_: Exception) {
82
+ // Ignore env setup failures; native layer will report verification errors.
83
+ }
84
+ if (client.getState() == MQTTClient.State.CONNECTED) {
85
+ client.disconnect()
86
+ }
87
+ client = MQTTClient(protocolVersion)
88
+ client.connect(host, port, clientId, username, password, cleanSession, keepalive, sessionExpiryInterval)
89
+ call.resolve(JSObject().put("connected", true))
90
+ } catch (e: Exception) {
91
+ call.reject(e.message ?: "Connection failed")
92
+ }
93
+ }
94
+ }
95
+
96
+ @PluginMethod
97
+ fun testHarness(call: PluginCall) {
98
+ val host = call.getString("host") ?: ""
99
+ val port = call.getInt("port") ?: 1884
100
+ val clientId = call.getString("clientId") ?: "mqttquic_test_client"
101
+ val topic = call.getString("topic") ?: "test/topic"
102
+ val payload = call.getString("payload") ?: "Hello QUIC!"
103
+ val caFile = call.getString("caFile")
104
+ val caPath = call.getString("caPath")
105
+
106
+ if (host.isEmpty()) {
107
+ call.reject("host is required")
108
+ return
109
+ }
110
+
111
+ scope.launch {
112
+ try {
113
+ try {
114
+ val bundled = bundledCaFilePath()
115
+ when {
116
+ caFile != null -> Os.setenv("MQTT_QUIC_CA_FILE", caFile, true)
117
+ bundled != null -> Os.setenv("MQTT_QUIC_CA_FILE", bundled, true)
118
+ else -> Os.setenv("MQTT_QUIC_CA_FILE", "", true)
119
+ }
120
+ if (caPath != null) {
121
+ Os.setenv("MQTT_QUIC_CA_PATH", caPath, true)
122
+ } else {
123
+ Os.setenv("MQTT_QUIC_CA_PATH", "", true)
124
+ }
125
+ } catch (_: Exception) {
126
+ // Ignore env setup failures; native layer will report verification errors.
127
+ }
128
+
129
+ client = MQTTClient(MQTTClient.ProtocolVersion.AUTO)
130
+ client.connect(host, port, clientId, null, null, true, 60, null)
131
+ client.subscribe(topic, 0, null)
132
+ client.publish(topic, payload.toByteArray(StandardCharsets.UTF_8), 0, null)
133
+ client.disconnect()
134
+ call.resolve(JSObject().put("success", true))
135
+ } catch (e: Exception) {
136
+ call.reject(e.message ?: "Test harness failed")
137
+ }
138
+ }
139
+ }
140
+
141
+ @PluginMethod
142
+ fun disconnect(call: PluginCall) {
143
+ scope.launch {
144
+ try {
145
+ client.disconnect()
146
+ call.resolve()
147
+ } catch (e: Exception) {
148
+ call.reject(e.message ?: "Disconnect failed")
149
+ }
150
+ }
151
+ }
152
+
153
+ @PluginMethod
154
+ fun publish(call: PluginCall) {
155
+ val topic = call.getString("topic") ?: ""
156
+ val qos = call.getInt("qos", 0)
157
+ val messageExpiryInterval = call.getInt("messageExpiryInterval")
158
+ val contentType = call.getString("contentType")
159
+
160
+ if (topic.isEmpty()) {
161
+ call.reject("topic is required")
162
+ return
163
+ }
164
+
165
+ val data: ByteArray = when {
166
+ call.getString("payload") != null ->
167
+ call.getString("payload")!!.toByteArray(StandardCharsets.UTF_8)
168
+ call.getArray("payload") != null -> {
169
+ val arr = call.getArray("payload")!!
170
+ (0 until arr.length()).mapNotNull { i ->
171
+ (arr.get(i) as? Number)?.toInt()?.and(0xFF)?.toByte()
172
+ }.toByteArray()
173
+ }
174
+ else -> {
175
+ call.reject("payload must be string or number array")
176
+ return
177
+ }
178
+ }
179
+
180
+ scope.launch {
181
+ try {
182
+ val properties = mutableMapOf<Int, Any>()
183
+ messageExpiryInterval?.let { properties[MQTT5PropertyType.MESSAGE_EXPIRY_INTERVAL.toInt()] = it }
184
+ contentType?.let { properties[MQTT5PropertyType.CONTENT_TYPE.toInt()] = it }
185
+ client.publish(topic, data, minOf(qos, 2), if (properties.isNotEmpty()) properties else null)
186
+ call.resolve(JSObject().put("success", true))
187
+ } catch (e: Exception) {
188
+ call.reject(e.message ?: "Publish failed")
189
+ }
190
+ }
191
+ }
192
+
193
+ @PluginMethod
194
+ fun subscribe(call: PluginCall) {
195
+ val topic = call.getString("topic") ?: ""
196
+ val qos = call.getInt("qos", 0)
197
+ val subscriptionIdentifier = call.getInt("subscriptionIdentifier")
198
+
199
+ if (topic.isEmpty()) {
200
+ call.reject("topic is required")
201
+ return
202
+ }
203
+
204
+ scope.launch {
205
+ try {
206
+ client.subscribe(topic, minOf(qos, 2), subscriptionIdentifier)
207
+ call.resolve(JSObject().put("success", true))
208
+ } catch (e: Exception) {
209
+ call.reject(e.message ?: "Subscribe failed")
210
+ }
211
+ }
212
+ }
213
+
214
+ @PluginMethod
215
+ fun unsubscribe(call: PluginCall) {
216
+ val topic = call.getString("topic") ?: ""
217
+
218
+ if (topic.isEmpty()) {
219
+ call.reject("topic is required")
220
+ return
221
+ }
222
+
223
+ scope.launch {
224
+ try {
225
+ client.unsubscribe(topic)
226
+ call.resolve(JSObject().put("success", true))
227
+ } catch (e: Exception) {
228
+ call.reject(e.message ?: "Unsubscribe failed")
229
+ }
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,339 @@
1
+ package ai.annadata.mqttquic.client
2
+
3
+ import ai.annadata.mqttquic.mqtt.MQTTConnAckCode
4
+ import ai.annadata.mqttquic.mqtt.MQTTMessageType
5
+ import ai.annadata.mqttquic.mqtt.MQTTProtocol
6
+ import ai.annadata.mqttquic.mqtt.MQTT5Protocol
7
+ import ai.annadata.mqttquic.mqtt.MQTT5ReasonCode
8
+ import ai.annadata.mqttquic.mqtt.MQTTProtocolLevel
9
+ import ai.annadata.mqttquic.quic.NGTCP2Client
10
+ import ai.annadata.mqttquic.quic.QuicClient
11
+ import ai.annadata.mqttquic.quic.QuicClientStub
12
+ import ai.annadata.mqttquic.quic.QuicStream
13
+ import ai.annadata.mqttquic.transport.MQTTStreamReader
14
+ import ai.annadata.mqttquic.transport.MQTTStreamWriter
15
+ import ai.annadata.mqttquic.transport.QUICStreamReader
16
+ import ai.annadata.mqttquic.transport.QUICStreamWriter
17
+ import kotlinx.coroutines.CoroutineScope
18
+ import kotlinx.coroutines.Dispatchers
19
+ import kotlinx.coroutines.isActive
20
+ import kotlinx.coroutines.launch
21
+ import kotlinx.coroutines.runBlocking
22
+ import kotlinx.coroutines.sync.Mutex
23
+ import kotlinx.coroutines.sync.withLock
24
+ import kotlinx.coroutines.SupervisorJob
25
+
26
+ /**
27
+ * High-level MQTT client: connect, publish, subscribe, disconnect.
28
+ * Uses QuicClient + stream adapters + MQTT protocol.
29
+ */
30
+ class MQTTClient {
31
+
32
+ enum class State {
33
+ DISCONNECTED,
34
+ CONNECTING,
35
+ CONNECTED,
36
+ ERROR
37
+ }
38
+
39
+ enum class ProtocolVersion {
40
+ V311, V5, AUTO
41
+ }
42
+
43
+ private var state = State.DISCONNECTED
44
+ private var protocolVersion = ProtocolVersion.AUTO
45
+ private var activeProtocolVersion: Byte = 0 // 0x04 or 0x05
46
+ private var quicClient: QuicClient? = null
47
+ private var stream: QuicStream? = null
48
+ private var reader: MQTTStreamReader? = null
49
+ private var writer: MQTTStreamWriter? = null
50
+ private var messageLoopJob: kotlinx.coroutines.Job? = null
51
+ private var nextPacketId = 1
52
+ private val subscribedTopics = mutableMapOf<String, (ByteArray) -> Unit>()
53
+ private val lock = Mutex()
54
+ private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
55
+
56
+ constructor(protocolVersion: ProtocolVersion = ProtocolVersion.AUTO) {
57
+ this.protocolVersion = protocolVersion
58
+ }
59
+
60
+ fun getState(): State = runBlocking { lock.withLock { state } }
61
+
62
+ suspend fun connect(
63
+ host: String,
64
+ port: Int,
65
+ clientId: String,
66
+ username: String?,
67
+ password: String?,
68
+ cleanSession: Boolean,
69
+ keepalive: Int,
70
+ sessionExpiryInterval: Int? = null
71
+ ) {
72
+ lock.withLock {
73
+ if (state == State.CONNECTING) {
74
+ throw IllegalStateException("already connecting")
75
+ }
76
+ state = State.CONNECTING
77
+ }
78
+
79
+ try {
80
+ val useV5 = protocolVersion == ProtocolVersion.V5 || protocolVersion == ProtocolVersion.AUTO
81
+
82
+ val connack: ByteArray
83
+ if (useV5) {
84
+ connack = MQTT5Protocol.buildConnackV5(MQTT5ReasonCode.SUCCESS, false)
85
+ } else {
86
+ connack = MQTTProtocol.buildConnack(MQTTConnAckCode.ACCEPTED)
87
+ }
88
+
89
+ val quic: QuicClient = if (NGTCP2Client.isAvailable()) {
90
+ NGTCP2Client()
91
+ } else {
92
+ QuicClientStub(connack.toList())
93
+ }
94
+ quic.connect(host, port)
95
+ val s = quic.openStream()
96
+ val r = QUICStreamReader(s)
97
+ val w = QUICStreamWriter(s)
98
+
99
+ lock.withLock {
100
+ quicClient = quic
101
+ stream = s
102
+ reader = r
103
+ writer = w
104
+ }
105
+
106
+ val connectData: ByteArray
107
+ if (useV5) {
108
+ connectData = MQTT5Protocol.buildConnectV5(
109
+ clientId,
110
+ username,
111
+ password,
112
+ keepalive,
113
+ cleanSession,
114
+ sessionExpiryInterval
115
+ )
116
+ activeProtocolVersion = MQTTProtocolLevel.V5
117
+ } else {
118
+ connectData = MQTTProtocol.buildConnect(
119
+ clientId,
120
+ username,
121
+ password,
122
+ keepalive,
123
+ cleanSession
124
+ )
125
+ activeProtocolVersion = MQTTProtocolLevel.V311
126
+ }
127
+
128
+ w.write(connectData)
129
+ w.drain()
130
+
131
+ val fixed = r.readexactly(2)
132
+ val (msgType, remLen, hdrLen) = MQTTProtocol.parseFixedHeader(fixed)
133
+ val rest = r.readexactly(remLen)
134
+ val full = fixed + rest
135
+ if (msgType != MQTTMessageType.CONNACK) {
136
+ lock.withLock { state = State.ERROR }
137
+ throw IllegalArgumentException("expected CONNACK, got $msgType")
138
+ }
139
+
140
+ if (activeProtocolVersion == MQTTProtocolLevel.V5) {
141
+ val (_, reasonCode, _) = MQTT5Protocol.parseConnackV5(full, hdrLen)
142
+ if (reasonCode != MQTT5ReasonCode.SUCCESS) {
143
+ lock.withLock { state = State.ERROR }
144
+ throw IllegalArgumentException("CONNACK refused: $reasonCode")
145
+ }
146
+ } else {
147
+ val (_, returnCode) = MQTTProtocol.parseConnack(full, hdrLen)
148
+ if (returnCode != MQTTConnAckCode.ACCEPTED) {
149
+ lock.withLock { state = State.ERROR }
150
+ throw IllegalArgumentException("CONNACK refused: $returnCode")
151
+ }
152
+ }
153
+
154
+ lock.withLock { state = State.CONNECTED }
155
+ startMessageLoop()
156
+ } catch (e: Exception) {
157
+ val wr = lock.withLock {
158
+ val w = writer
159
+ quicClient = null
160
+ stream = null
161
+ reader = null
162
+ writer = null
163
+ state = State.ERROR
164
+ w
165
+ }
166
+ try {
167
+ wr?.close()
168
+ } catch (_: Exception) { /* ignore */ }
169
+ throw e
170
+ }
171
+ }
172
+
173
+ suspend fun publish(topic: String, payload: ByteArray, qos: Int, properties: Map<Int, Any>? = null) {
174
+ if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
175
+ val (w, version) = lock.withLock { writer to activeProtocolVersion }
176
+ if (w == null) throw IllegalStateException("no writer")
177
+
178
+ val pid: Int? = if (qos > 0) nextPacketIdUsed() else null
179
+ val data: ByteArray
180
+ if (version == MQTTProtocolLevel.V5) {
181
+ data = MQTT5Protocol.buildPublishV5(topic, payload, pid, qos, false, properties)
182
+ } else {
183
+ data = MQTTProtocol.buildPublish(topic, payload, pid, qos, false)
184
+ }
185
+ w.write(data)
186
+ w.drain()
187
+ }
188
+
189
+ suspend fun subscribe(topic: String, qos: Int, subscriptionIdentifier: Int? = null) {
190
+ if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
191
+ val (r, w, version) = lock.withLock { Triple(reader, writer, activeProtocolVersion) }
192
+ if (r == null || w == null) throw IllegalStateException("no reader/writer")
193
+
194
+ val pid = nextPacketIdUsed()
195
+ val data: ByteArray
196
+ if (version == MQTTProtocolLevel.V5) {
197
+ data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
198
+ } else {
199
+ data = MQTTProtocol.buildSubscribe(pid, topic, qos)
200
+ }
201
+ w.write(data)
202
+ w.drain()
203
+
204
+ val fixed = r.readexactly(2)
205
+ val (_, remLen, hdrLen) = MQTTProtocol.parseFixedHeader(fixed)
206
+ val rest = r.readexactly(remLen)
207
+ val full = fixed + rest
208
+
209
+ if (version == MQTTProtocolLevel.V5) {
210
+ val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
211
+ if (reasonCodes.isNotEmpty()) {
212
+ val firstRC = reasonCodes[0]
213
+ if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
214
+ throw IllegalArgumentException("SUBACK error $firstRC")
215
+ }
216
+ }
217
+ } else {
218
+ val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
219
+ if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
220
+ }
221
+ }
222
+
223
+ suspend fun unsubscribe(topic: String) {
224
+ if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
225
+ val (r, w, version) = lock.withLock {
226
+ subscribedTopics.remove(topic)
227
+ Triple(reader, writer, activeProtocolVersion)
228
+ }
229
+ if (r == null || w == null) throw IllegalStateException("no reader/writer")
230
+
231
+ val pid = nextPacketIdUsed()
232
+ val data: ByteArray
233
+ if (version == MQTTProtocolLevel.V5) {
234
+ data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
235
+ } else {
236
+ data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
237
+ }
238
+ w.write(data)
239
+ w.drain()
240
+
241
+ val fixed = r.readexactly(2)
242
+ val (_, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
243
+ r.readexactly(remLen)
244
+ }
245
+
246
+ suspend fun disconnect() {
247
+ val job = messageLoopJob
248
+ messageLoopJob = null
249
+ job?.cancel()
250
+ job?.join()
251
+
252
+ val (w, version) = lock.withLock {
253
+ val wr = writer
254
+ val v = activeProtocolVersion
255
+ quicClient = null
256
+ stream = null
257
+ reader = null
258
+ writer = null
259
+ state = State.DISCONNECTED
260
+ activeProtocolVersion = 0
261
+ wr to v
262
+ }
263
+
264
+ w?.let {
265
+ val data: ByteArray
266
+ if (version == MQTTProtocolLevel.V5) {
267
+ data = MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.NORMAL_DISCONNECTION_DISC)
268
+ } else {
269
+ data = MQTTProtocol.buildDisconnect()
270
+ }
271
+ try {
272
+ it.write(data)
273
+ it.drain()
274
+ it.close()
275
+ } catch (e: Exception) {
276
+ // Ignore errors during disconnect
277
+ }
278
+ }
279
+ }
280
+
281
+ fun onMessage(topic: String, callback: (ByteArray) -> Unit) {
282
+ kotlinx.coroutines.runBlocking {
283
+ lock.withLock {
284
+ subscribedTopics[topic] = callback
285
+ }
286
+ }
287
+ }
288
+
289
+ private suspend fun nextPacketIdUsed(): Int = lock.withLock {
290
+ val pid = nextPacketId
291
+ nextPacketId = (nextPacketId + 1) % 65536
292
+ if (nextPacketId == 0) nextPacketId = 1
293
+ pid
294
+ }
295
+
296
+ private fun startMessageLoop() {
297
+ messageLoopJob = scope.launch {
298
+ while (isActive) {
299
+ val r = lock.withLock { reader } ?: break
300
+ try {
301
+ val fixed = r.readexactly(2)
302
+ val (msgType, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
303
+ val rest = r.readexactly(remLen)
304
+ val type = (msgType.toInt() and 0xF0).toByte()
305
+ when (type) {
306
+ MQTTMessageType.PINGREQ -> {
307
+ val w = lock.withLock { writer }
308
+ w?.let {
309
+ val pr = MQTTProtocol.buildPingresp()
310
+ it.write(pr)
311
+ it.drain()
312
+ }
313
+ }
314
+ MQTTMessageType.PUBLISH -> {
315
+ val qos = ((msgType.toInt() shr 1) and 0x03).toByte()
316
+ val (topic, packetId, payload) = MQTTProtocol.parsePublish(rest, 0, qos.toInt())
317
+
318
+ val (cb, _) = lock.withLock {
319
+ subscribedTopics[topic] to activeProtocolVersion
320
+ }
321
+ cb?.invoke(payload)
322
+
323
+ if (qos.toInt() >= 1 && packetId != null) {
324
+ val w = lock.withLock { writer }
325
+ w?.let {
326
+ val puback = MQTTProtocol.buildPuback(packetId)
327
+ it.write(puback)
328
+ it.drain()
329
+ }
330
+ }
331
+ }
332
+ }
333
+ } catch (e: Exception) {
334
+ if (!isActive) break
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }