@annadata/capacitor-mqtt-quic 0.1.8 → 0.1.9

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 (67) hide show
  1. package/android/build-wolfssl.sh +8 -2
  2. package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.a +0 -0
  3. package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.so +0 -0
  4. package/android/install/nghttp3-android/arm64-v8a/lib/pkgconfig/libnghttp3.pc +4 -4
  5. package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.a +0 -0
  6. package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.so +0 -0
  7. package/android/install/nghttp3-android/armeabi-v7a/lib/pkgconfig/libnghttp3.pc +4 -4
  8. package/android/install/nghttp3-android/x86_64/lib/libnghttp3.a +0 -0
  9. package/android/install/nghttp3-android/x86_64/lib/libnghttp3.so +0 -0
  10. package/android/install/nghttp3-android/x86_64/lib/pkgconfig/libnghttp3.pc +4 -4
  11. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.a +0 -0
  12. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.so +0 -0
  13. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.a +0 -0
  14. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.so +0 -0
  15. package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2.pc +4 -4
  16. package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  17. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.a +0 -0
  18. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.so +0 -0
  19. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.a +0 -0
  20. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.so +0 -0
  21. package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2.pc +4 -4
  22. package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  23. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.a +0 -0
  24. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.so +0 -0
  25. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.a +0 -0
  26. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.so +0 -0
  27. package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2.pc +4 -4
  28. package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  29. package/android/install/wolfssl-android/arm64-v8a/bin/wolfssl-config +1 -1
  30. package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.a +0 -0
  31. package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.la +1 -1
  32. package/android/install/wolfssl-android/arm64-v8a/lib/pkgconfig/wolfssl.pc +1 -1
  33. package/android/install/wolfssl-android/armeabi-v7a/bin/wolfssl-config +1 -1
  34. package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.a +0 -0
  35. package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.la +1 -1
  36. package/android/install/wolfssl-android/armeabi-v7a/lib/pkgconfig/wolfssl.pc +1 -1
  37. package/android/install/wolfssl-android/x86_64/bin/wolfssl-config +1 -1
  38. package/android/install/wolfssl-android/x86_64/lib/libwolfssl.a +0 -0
  39. package/android/install/wolfssl-android/x86_64/lib/libwolfssl.la +1 -1
  40. package/android/install/wolfssl-android/x86_64/lib/pkgconfig/wolfssl.pc +1 -1
  41. package/android/src/main/cpp/ngtcp2_jni.cpp +94 -19
  42. package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +58 -3
  43. package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +230 -80
  44. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +33 -2
  45. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +25 -15
  46. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +1 -1
  47. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +1 -1
  48. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +80 -5
  49. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +4 -0
  50. package/docs/diff-node_modules-vs-standalone-android-src.patch +1031 -0
  51. package/ios/build-wolfssl.sh +8 -3
  52. package/ios/libs/libnghttp3.a +0 -0
  53. package/ios/libs/libngtcp2.a +0 -0
  54. package/ios/libs/libngtcp2_crypto_wolfssl.a +0 -0
  55. package/ios/libs/libwolfssl.a +0 -0
  56. package/ios/libs-simulator/libnghttp3.a +0 -0
  57. package/ios/libs-simulator/libngtcp2.a +0 -0
  58. package/ios/libs-simulator/libngtcp2_crypto_wolfssl.a +0 -0
  59. package/ios/libs-simulator/libwolfssl.a +0 -0
  60. package/ios/libs-simulator-x86_64/libnghttp3.a +0 -0
  61. package/ios/libs-simulator-x86_64/libngtcp2.a +0 -0
  62. package/ios/libs-simulator-x86_64/libngtcp2_crypto_wolfssl.a +0 -0
  63. package/ios/libs-simulator-x86_64/libwolfssl.a +0 -0
  64. package/package.json +1 -1
  65. package/ios/libs/MqttQuicLibs.xcframework/Info.plist +0 -44
  66. package/ios/libs/MqttQuicLibs.xcframework/ios-arm64/libmqttquic_native_device.a +0 -0
  67. package/ios/libs/MqttQuicLibs.xcframework/ios-arm64_x86_64-simulator/libmqttquic_native_simulator.a +0 -0
@@ -1,5 +1,6 @@
1
1
  package ai.annadata.mqttquic.client
2
2
 
3
+ import android.util.Log
3
4
  import ai.annadata.mqttquic.mqtt.MQTT5PropertyType
4
5
  import ai.annadata.mqttquic.mqtt.MQTTConnAckCode
5
6
  import ai.annadata.mqttquic.mqtt.MQTTMessageType
@@ -15,6 +16,7 @@ import ai.annadata.mqttquic.transport.MQTTStreamReader
15
16
  import ai.annadata.mqttquic.transport.MQTTStreamWriter
16
17
  import ai.annadata.mqttquic.transport.QUICStreamReader
17
18
  import ai.annadata.mqttquic.transport.QUICStreamWriter
19
+ import kotlinx.coroutines.CompletableDeferred
18
20
  import kotlinx.coroutines.CoroutineScope
19
21
  import kotlinx.coroutines.Dispatchers
20
22
  import kotlinx.coroutines.delay
@@ -24,6 +26,9 @@ import kotlinx.coroutines.runBlocking
24
26
  import kotlinx.coroutines.sync.Mutex
25
27
  import kotlinx.coroutines.sync.withLock
26
28
  import kotlinx.coroutines.SupervisorJob
29
+ import kotlinx.coroutines.CancellationException
30
+ import kotlinx.coroutines.TimeoutCancellationException
31
+ import kotlinx.coroutines.withTimeout
27
32
 
28
33
  /**
29
34
  * High-level MQTT client: connect, publish, subscribe, disconnect.
@@ -62,6 +67,10 @@ class MQTTClient {
62
67
  var onPublish: ((String, ByteArray) -> Unit)? = null
63
68
  /** Per-connection Topic Alias map (alias -> topic name) for MQTT 5.0 incoming PUBLISH. */
64
69
  private val topicAliasMap = mutableMapOf<Int, String>()
70
+ /** Pending SUBACK by packet ID. Message loop completes with (fullPacket, hdrLen). Single reader: only message loop reads stream. */
71
+ private val pendingSubacks = mutableMapOf<Int, CompletableDeferred<Pair<ByteArray, Int>>>()
72
+ /** Pending UNSUBACK by packet ID. Message loop completes when UNSUBACK is read. */
73
+ private val pendingUnsubacks = mutableMapOf<Int, CompletableDeferred<Unit>>()
65
74
  private val lock = Mutex()
66
75
  private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
67
76
 
@@ -77,6 +86,29 @@ class MQTTClient {
77
86
  /** Assigned Client Identifier from CONNACK when client sent empty ClientID; null otherwise. */
78
87
  fun getAssignedClientIdentifier(): String? = runBlocking { lock.withLock { assignedClientIdentifier } }
79
88
 
89
+ /** Resolved IP used for the current/last QUIC connection (from native getaddrinfo). Used by plugin to cache for reconnect when Java DNS fails. */
90
+ fun getLastResolvedAddress(): String? = (quicClient as? NGTCP2Client)?.getLastResolvedAddress()
91
+
92
+ /** Read full MQTT fixed header (1 byte type + 1–4 bytes remaining length per MQTT v5.0 §2.1.4). Returns (msgType, remLen, fixedHeaderBytes). */
93
+ private suspend fun readFixedHeader(r: MQTTStreamReader): Triple<Byte, Int, ByteArray> {
94
+ Log.i("MQTTClient", "readFixedHeader: requesting first byte")
95
+ var fixed = r.readexactly(1).toMutableList()
96
+ val firstByte = fixed[0].toInt() and 0xFF
97
+ Log.i("MQTTClient", "readFixedHeader: got type byte 0x${Integer.toHexString(firstByte)} ($firstByte)")
98
+ repeat(5) { // 1 type + up to 4 remaining-length bytes (Variable Byte Integer)
99
+ try {
100
+ val (rem, _) = MQTTProtocol.decodeRemainingLength(fixed.toByteArray(), 1)
101
+ Log.i("MQTTClient", "readFixedHeader: decoded remLen=$rem fixedSize=${fixed.size}")
102
+ return Triple(fixed[0], rem, fixed.toByteArray())
103
+ } catch (_: IllegalArgumentException) {
104
+ if (fixed.size > 5) throw IllegalArgumentException("Invalid remaining length")
105
+ fixed.addAll(r.readexactly(1).toList())
106
+ Log.i("MQTTClient", "readFixedHeader: added byte, fixedSize=${fixed.size}")
107
+ }
108
+ }
109
+ throw IllegalArgumentException("Invalid remaining length")
110
+ }
111
+
80
112
  suspend fun connect(
81
113
  host: String,
82
114
  port: Int,
@@ -85,7 +117,8 @@ class MQTTClient {
85
117
  password: String?,
86
118
  cleanSession: Boolean,
87
119
  keepalive: Int,
88
- sessionExpiryInterval: Int? = null
120
+ sessionExpiryInterval: Int? = null,
121
+ connectAddress: String? = null
89
122
  ) {
90
123
  lock.withLock {
91
124
  if (state == State.CONNECTING) {
@@ -109,7 +142,7 @@ class MQTTClient {
109
142
  } else {
110
143
  QuicClientStub(connack.toList())
111
144
  }
112
- quic.connect(host, port)
145
+ quic.connect(host, port, connectAddress)
113
146
  val s = quic.openStream()
114
147
  val r = QUICStreamReader(s)
115
148
  val w = QUICStreamWriter(s)
@@ -146,50 +179,118 @@ class MQTTClient {
146
179
  w.write(connectData)
147
180
  w.drain()
148
181
 
149
- // MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK
182
+ // MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK. Timeout so we don't block 30s until ERR_IDLE_CLOSE.
183
+ // Efficient path: drain stream (read until empty), then parse first complete packet; repeat until CONNACK or timeout.
150
184
  var full: ByteArray
151
185
  var hdrLen: Int
152
- if (activeProtocolVersion == MQTTProtocolLevel.V5) {
153
- while (true) {
154
- val fixed = r.readexactly(2)
155
- val (msgType, remLen, hLen) = MQTTProtocol.parseFixedHeader(fixed)
156
- val rest = r.readexactly(remLen)
157
- full = fixed + rest
158
- hdrLen = hLen
159
- when (msgType) {
160
- MQTTMessageType.CONNACK -> break
161
- MQTTMessageType.AUTH -> {
162
- lock.withLock { state = State.ERROR }
163
- try {
164
- w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
165
- w.drain()
166
- w.close()
167
- } catch (_: Exception) { /* ignore */ }
168
- throw IllegalArgumentException("Enhanced authentication not supported")
169
- }
170
- MQTTMessageType.DISCONNECT -> {
171
- lock.withLock { state = State.ERROR }
172
- throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
186
+ val connackTimeoutMs = 15_000L
187
+ try {
188
+ if (activeProtocolVersion == MQTTProtocolLevel.V5) {
189
+ Log.i("MQTTClient", "reading CONNACK (MQTT5) timeout=${connackTimeoutMs}ms (drain then parse)")
190
+ withTimeout(connackTimeoutMs) {
191
+ while (true) {
192
+ if (r is QUICStreamReader) {
193
+ r.drain()
194
+ val avail = r.available()
195
+ Log.i("MQTTClient", "CONNACK loop: after drain available=$avail")
196
+ val packet = r.tryConsumeNextPacket()
197
+ if (packet != null) {
198
+ val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
199
+ val typeByte = msgType.toInt() and 0xFF
200
+ Log.i("MQTTClient", "CONNACK loop: packet type=0x${Integer.toHexString(typeByte)} len=${packet.size} hdrLen=$fixedLen")
201
+ when (typeByte) {
202
+ 0x20 -> {
203
+ full = packet
204
+ hdrLen = fixedLen
205
+ return@withTimeout
206
+ }
207
+ 0xF0 -> {
208
+ lock.withLock { state = State.ERROR }
209
+ try {
210
+ w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
211
+ w.drain()
212
+ w.close()
213
+ } catch (_: Exception) { /* ignore */ }
214
+ throw IllegalArgumentException("Enhanced authentication not supported")
215
+ }
216
+ 0xE0 -> {
217
+ lock.withLock { state = State.ERROR }
218
+ throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
219
+ }
220
+ else -> Log.i("MQTTClient", "CONNACK loop: skipping non-CONNACK packet type=0x${Integer.toHexString(typeByte)}")
221
+ }
222
+ } else {
223
+ // Only delay when no data; if we have data but no packet, retry soon (avoid suspend then timeout before next iteration)
224
+ if (avail == 0) delay(50) else delay(10)
225
+ }
226
+ } else {
227
+ // Fallback for non-QUIC reader (e.g. mock)
228
+ val (msgType, remLen, fixed) = readFixedHeader(r)
229
+ val typeByte = fixed[0].toInt() and 0xFF
230
+ val rest = r.readexactly(remLen)
231
+ full = fixed + rest
232
+ hdrLen = fixed.size
233
+ when (typeByte) {
234
+ 0x20 -> return@withTimeout
235
+ 0xF0 -> {
236
+ lock.withLock { state = State.ERROR }
237
+ try {
238
+ w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
239
+ w.drain()
240
+ w.close()
241
+ } catch (_: Exception) { /* ignore */ }
242
+ throw IllegalArgumentException("Enhanced authentication not supported")
243
+ }
244
+ 0xE0 -> {
245
+ lock.withLock { state = State.ERROR }
246
+ throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
247
+ }
248
+ else -> { /* skip; continue */ }
249
+ }
250
+ }
173
251
  }
174
- else -> {
175
- lock.withLock { state = State.ERROR }
176
- throw IllegalArgumentException("Expected CONNACK or AUTH, got $msgType")
252
+ }
253
+ Log.i("MQTTClient", "got CONNACK (MQTT5) (fullLen=${full.size} hdrLen=$hdrLen)")
254
+ } else {
255
+ Log.i("MQTTClient", "reading CONNACK (3.1.1) timeout=${connackTimeoutMs}ms")
256
+ withTimeout(connackTimeoutMs) {
257
+ if (r is QUICStreamReader) {
258
+ while (true) {
259
+ r.drain()
260
+ val packet = r.tryConsumeNextPacket()
261
+ if (packet != null) {
262
+ val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
263
+ if (msgType == MQTTMessageType.CONNACK) {
264
+ full = packet
265
+ hdrLen = fixedLen
266
+ break
267
+ }
268
+ lock.withLock { state = State.ERROR }
269
+ throw IllegalArgumentException("expected CONNACK, got $msgType")
270
+ }
271
+ delay(50)
272
+ }
273
+ } else {
274
+ val (msgType, remLen, fixed) = readFixedHeader(r)
275
+ val rest = r.readexactly(remLen)
276
+ full = fixed + rest
277
+ hdrLen = fixed.size
278
+ if (msgType != MQTTMessageType.CONNACK) {
279
+ lock.withLock { state = State.ERROR }
280
+ throw IllegalArgumentException("expected CONNACK, got $msgType")
281
+ }
177
282
  }
283
+ Log.i("MQTTClient", "got CONNACK (3.1.1)")
178
284
  }
179
285
  }
180
- } else {
181
- val fixed = r.readexactly(2)
182
- val (msgType, remLen, hLen) = MQTTProtocol.parseFixedHeader(fixed)
183
- val rest = r.readexactly(remLen)
184
- full = fixed + rest
185
- hdrLen = hLen
186
- if (msgType != MQTTMessageType.CONNACK) {
187
- lock.withLock { state = State.ERROR }
188
- throw IllegalArgumentException("expected CONNACK, got $msgType")
189
- }
286
+ } catch (e: TimeoutCancellationException) {
287
+ lock.withLock { state = State.ERROR }
288
+ Log.w("MQTTClient", "CONNACK read timed out after ${connackTimeoutMs}ms", e)
289
+ throw IllegalStateException("CONNACK read timed out. Drain+parse did not receive a complete CONNACK in time. Ask server to send full CONNACK (loop writev_stream until all bytes sent); or check network.", e)
190
290
  }
191
291
 
192
292
  if (activeProtocolVersion == MQTTProtocolLevel.V5) {
293
+ Log.i("MQTTClient", "parsing CONNACK v5 (offset=$hdrLen)")
193
294
  val (_, reasonCode, props) = MQTT5Protocol.parseConnackV5(full, hdrLen)
194
295
  if (reasonCode != MQTT5ReasonCode.SUCCESS) {
195
296
  lock.withLock { state = State.ERROR }
@@ -210,21 +311,37 @@ class MQTTClient {
210
311
  }
211
312
 
212
313
  lock.withLock { state = State.CONNECTED }
314
+ Log.i("MQTTClient", "state=CONNECTED, starting message and keepalive loops")
213
315
  startMessageLoop()
214
316
  startKeepaliveLoop()
215
317
  } catch (e: Exception) {
216
- val wr = lock.withLock {
318
+ val (wr, quic) = lock.withLock {
217
319
  val w = writer
320
+ val q = quicClient
218
321
  quicClient = null
219
322
  stream = null
220
323
  reader = null
221
324
  writer = null
222
325
  state = State.ERROR
223
- w
326
+ Pair(w, q)
224
327
  }
225
328
  try {
226
329
  wr?.close()
227
330
  } catch (_: Exception) { /* ignore */ }
331
+ // Skip quic.close() on timeout/cancellation: server may have already sent idle close, and native close() can crash. Prefer leak over crash.
332
+ val isTimeoutOrCancel = e is TimeoutCancellationException ||
333
+ e is CancellationException ||
334
+ e.cause is TimeoutCancellationException ||
335
+ (e.message?.contains("Timed out", ignoreCase = true) == true)
336
+ if (!isTimeoutOrCancel) {
337
+ try {
338
+ quic?.close()
339
+ } catch (ex: Exception) {
340
+ Log.w("MQTTClient", "Error closing QUIC connection on connect failure", ex)
341
+ }
342
+ } else {
343
+ Log.i("MQTTClient", "Skipping quic.close() on timeout/cancellation to avoid native crash")
344
+ }
228
345
  throw e
229
346
  }
230
347
  }
@@ -260,59 +377,66 @@ class MQTTClient {
260
377
 
261
378
  suspend fun subscribe(topic: String, qos: Int, subscriptionIdentifier: Int? = null) {
262
379
  if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
263
- val (r, w, version) = lock.withLock { Triple(reader, writer, activeProtocolVersion) }
264
- if (r == null || w == null) throw IllegalStateException("no reader/writer")
380
+ val (w, version) = lock.withLock { writer to activeProtocolVersion }
381
+ if (w == null) throw IllegalStateException("no writer")
265
382
 
266
383
  val pid = nextPacketIdUsed()
267
- val data: ByteArray
268
- if (version == MQTTProtocolLevel.V5) {
269
- data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
270
- } else {
271
- data = MQTTProtocol.buildSubscribe(pid, topic, qos)
272
- }
273
- w.write(data)
274
- w.drain()
275
-
276
- val fixed = r.readexactly(2)
277
- val (_, remLen, hdrLen) = MQTTProtocol.parseFixedHeader(fixed)
278
- val rest = r.readexactly(remLen)
279
- val full = fixed + rest
280
-
281
- if (version == MQTTProtocolLevel.V5) {
282
- val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
283
- if (reasonCodes.isNotEmpty()) {
284
- val firstRC = reasonCodes[0]
285
- if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
286
- throw IllegalArgumentException("SUBACK error $firstRC")
384
+ val deferred = CompletableDeferred<Pair<ByteArray, Int>>()
385
+ lock.withLock { pendingSubacks[pid] = deferred }
386
+ try {
387
+ val data: ByteArray
388
+ if (version == MQTTProtocolLevel.V5) {
389
+ data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
390
+ } else {
391
+ data = MQTTProtocol.buildSubscribe(pid, topic, qos)
392
+ }
393
+ w.write(data)
394
+ w.drain()
395
+
396
+ val (full, hdrLen) = withTimeout(15_000L) { deferred.await() }
397
+
398
+ if (version == MQTTProtocolLevel.V5) {
399
+ val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
400
+ if (reasonCodes.isNotEmpty()) {
401
+ val firstRC = reasonCodes[0]
402
+ if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
403
+ throw IllegalArgumentException("SUBACK error $firstRC")
404
+ }
287
405
  }
406
+ } else {
407
+ val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
408
+ if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
288
409
  }
289
- } else {
290
- val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
291
- if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
410
+ } finally {
411
+ lock.withLock { pendingSubacks.remove(pid) }
292
412
  }
293
413
  }
294
414
 
295
415
  suspend fun unsubscribe(topic: String) {
296
416
  if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
297
- val (r, w, version) = lock.withLock {
417
+ val (w, version) = lock.withLock {
298
418
  subscribedTopics.remove(topic)
299
- Triple(reader, writer, activeProtocolVersion)
419
+ writer to activeProtocolVersion
300
420
  }
301
- if (r == null || w == null) throw IllegalStateException("no reader/writer")
421
+ if (w == null) throw IllegalStateException("no writer")
302
422
 
303
423
  val pid = nextPacketIdUsed()
304
- val data: ByteArray
305
- if (version == MQTTProtocolLevel.V5) {
306
- data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
307
- } else {
308
- data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
309
- }
310
- w.write(data)
311
- w.drain()
424
+ val deferred = CompletableDeferred<Unit>()
425
+ lock.withLock { pendingUnsubacks[pid] = deferred }
426
+ try {
427
+ val data: ByteArray
428
+ if (version == MQTTProtocolLevel.V5) {
429
+ data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
430
+ } else {
431
+ data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
432
+ }
433
+ w.write(data)
434
+ w.drain()
312
435
 
313
- val fixed = r.readexactly(2)
314
- val (_, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
315
- r.readexactly(remLen)
436
+ withTimeout(15_000L) { deferred.await() }
437
+ } finally {
438
+ lock.withLock { pendingUnsubacks.remove(pid) }
439
+ }
316
440
  }
317
441
 
318
442
  suspend fun disconnect() {
@@ -323,6 +447,8 @@ class MQTTClient {
323
447
  job?.cancel()
324
448
  job?.join()
325
449
 
450
+ failPendingSubacksUnsubacks(IllegalStateException("Disconnected"))
451
+
326
452
  val (w, version) = lock.withLock {
327
453
  val wr = writer
328
454
  val v = activeProtocolVersion
@@ -369,6 +495,15 @@ class MQTTClient {
369
495
  pid
370
496
  }
371
497
 
498
+ private suspend fun failPendingSubacksUnsubacks(cause: Throwable) {
499
+ lock.withLock {
500
+ pendingSubacks.values.forEach { it.completeExceptionally(cause) }
501
+ pendingSubacks.clear()
502
+ pendingUnsubacks.values.forEach { it.completeExceptionally(cause) }
503
+ pendingUnsubacks.clear()
504
+ }
505
+ }
506
+
372
507
  /** Send PINGREQ at effectiveKeepalive interval so server sees activity and does not close (idle/keepalive). [MQTT-3.1.2-20] */
373
508
  private fun startKeepaliveLoop() {
374
509
  keepaliveJob?.cancel()
@@ -394,11 +529,24 @@ class MQTTClient {
394
529
  while (isActive) {
395
530
  val r = lock.withLock { reader } ?: break
396
531
  try {
397
- val fixed = r.readexactly(2)
398
- val (msgType, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
532
+ val (msgType, remLen, fixed) = readFixedHeader(r)
399
533
  val rest = r.readexactly(remLen)
400
534
  val type = (msgType.toInt() and 0xF0).toByte()
401
535
  when (type) {
536
+ MQTTMessageType.SUBACK -> {
537
+ if (rest.size >= 2) {
538
+ val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
539
+ val full = fixed + rest
540
+ val hdrLen = fixed.size
541
+ lock.withLock { pendingSubacks.remove(pid)?.complete(Pair(full, hdrLen)) }
542
+ }
543
+ }
544
+ MQTTMessageType.UNSUBACK -> {
545
+ if (rest.size >= 2) {
546
+ val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
547
+ lock.withLock { pendingUnsubacks.remove(pid)?.complete(Unit) }
548
+ }
549
+ }
402
550
  MQTTMessageType.DISCONNECT -> {
403
551
  val reasonCode = if (rest.isNotEmpty()) rest[0].toInt() and 0xFF else 0x00
404
552
  lock.withLock {
@@ -412,6 +560,7 @@ class MQTTClient {
412
560
  topicAliasMap.clear()
413
561
  state = if (reasonCode >= 0x80) State.ERROR else State.DISCONNECTED
414
562
  }
563
+ failPendingSubacksUnsubacks(IllegalStateException("Server sent DISCONNECT"))
415
564
  break
416
565
  }
417
566
  MQTTMessageType.PINGREQ -> {
@@ -473,6 +622,7 @@ class MQTTClient {
473
622
  topicAliasMap.clear()
474
623
  state = State.DISCONNECTED
475
624
  }
625
+ failPendingSubacksUnsubacks(e)
476
626
  }
477
627
  break
478
628
  }
@@ -40,10 +40,10 @@ object MQTTProtocol {
40
40
  val b = data[i].toInt() and 0xFF
41
41
  len += (b and 0x7F) * mul
42
42
  i++
43
- if ((b and 0x80) == 0) return@repeat
43
+ if ((b and 0x80) == 0) return len to (i - offset)
44
44
  mul *= 128
45
45
  }
46
- return len to (i - offset)
46
+ throw IllegalArgumentException("Invalid remaining length (max 4 bytes)")
47
47
  }
48
48
 
49
49
  fun encodeString(s: String): ByteArray {
@@ -75,6 +75,37 @@ object MQTTProtocol {
75
75
  return Triple(msgType, rem, 1 + consumed)
76
76
  }
77
77
 
78
+ /**
79
+ * Returns total MQTT packet length (fixed header + payload) if buffer has at least a decodable fixed header, else null.
80
+ * Use when draining stream: accumulate bytes, then call this; when buffer.size >= length, you have a complete packet.
81
+ */
82
+ fun getNextPacketLength(buffer: ByteArray): Int? {
83
+ if (buffer.size < 2) return null
84
+ // CONNACK (0x20): often single-byte remaining length (e.g. 0x20 = 32 → total 34). Handle that first.
85
+ if (buffer[0].toInt() and 0xFF == 0x20) {
86
+ val b1 = buffer[1].toInt() and 0xFF
87
+ if ((b1 and 0x80) == 0) {
88
+ val rem = b1
89
+ val total = 1 + 1 + rem
90
+ if (total <= buffer.size) return total
91
+ } else {
92
+ try {
93
+ val (rem, consumed) = decodeRemainingLength(buffer, 1)
94
+ val total = 1 + consumed + rem
95
+ if (total <= buffer.size) return total
96
+ } catch (_: IllegalArgumentException) { /* fall through */ }
97
+ }
98
+ }
99
+ for (len in minOf(5, buffer.size) downTo 2) {
100
+ try {
101
+ val (_, rem, fixedLen) = parseFixedHeader(buffer.copyOf(len))
102
+ val total = fixedLen + rem
103
+ if (total in 1..buffer.size) return total
104
+ } catch (_: IllegalArgumentException) { /* need more bytes for remaining length */ }
105
+ }
106
+ return null
107
+ }
108
+
78
109
  fun buildConnect(
79
110
  clientId: String,
80
111
  username: String? = null,
@@ -40,22 +40,28 @@ class NGTCP2Client : QuicClient {
40
40
 
41
41
  // Native methods (implemented in ngtcp2_jni.cpp)
42
42
  private external fun nativeCreateConnection(host: String, port: Int): Long
43
+ private external fun nativeCreateConnectionWithAddress(hostnameForTls: String, connectAddress: String, port: Int): Long
43
44
  private external fun nativeConnect(connHandle: Long): Int
44
45
  private external fun nativeOpenStream(connHandle: Long): Long
45
46
  private external fun nativeWriteStream(connHandle: Long, streamId: Long, data: ByteArray): Int
46
47
  private external fun nativeReadStream(connHandle: Long, streamId: Long): ByteArray?
47
48
  private external fun nativeClose(connHandle: Long)
48
49
  private external fun nativeIsConnected(connHandle: Long): Boolean
49
- internal external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
50
+ // Public to avoid Kotlin internal-name mangling (nativeCloseStream$module) which breaks JNI lookup
51
+ external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
50
52
  @JvmName("nativeGetLastError")
51
- internal external fun nativeGetLastError(connHandle: Long): String
52
-
53
+ external fun nativeGetLastError(connHandle: Long): String
54
+ private external fun nativeGetLastResolvedAddress(connHandle: Long): String?
55
+
56
+ /** Resolved IP used for this connection (set after init_socket in native). Use for reconnect cache when Java DNS fails. */
57
+ fun getLastResolvedAddress(): String? = if (connHandle != 0L) nativeGetLastResolvedAddress(connHandle) else null
58
+
53
59
  // Connection state
54
60
  private var connHandle: Long = 0
55
61
  private var isConnected: Boolean = false
56
62
  private val streams = mutableMapOf<Long, NGTCP2Stream>()
57
63
 
58
- override suspend fun connect(host: String, port: Int) {
64
+ override suspend fun connect(host: String, port: Int, connectAddress: String?) {
59
65
  if (!isAvailable()) {
60
66
  throw IllegalStateException("ngtcp2 native library is not loaded")
61
67
  }
@@ -63,8 +69,12 @@ class NGTCP2Client : QuicClient {
63
69
  throw IllegalStateException("Already connected")
64
70
  }
65
71
 
66
- // Create native connection
67
- connHandle = nativeCreateConnection(host, port)
72
+ // Create native connection (use pre-resolved IP when given to avoid "No address" on reconnect)
73
+ connHandle = if (!connectAddress.isNullOrBlank()) {
74
+ nativeCreateConnectionWithAddress(host, connectAddress, port)
75
+ } else {
76
+ nativeCreateConnection(host, port)
77
+ }
68
78
  if (connHandle == 0L) {
69
79
  throw IllegalStateException("Failed to create QUIC connection")
70
80
  }
@@ -147,19 +157,19 @@ internal class NGTCP2Stream(
147
157
 
148
158
  private var isClosed: Boolean = false
149
159
 
160
+ /**
161
+ * Read up to maxBytes from the stream. Returns immediately with whatever is
162
+ * currently available (possibly empty). This allows drain()-then-parse logic
163
+ * to complete: first read returns CONNACK bytes, second read returns empty
164
+ * so drain() breaks and tryConsumeNextPacket() can run. Never blocks waiting
165
+ * for more data (native recv_buf is already populated by the event loop).
166
+ */
150
167
  override suspend fun read(maxBytes: Int): ByteArray {
151
168
  if (isClosed) {
152
169
  throw IllegalStateException("Stream is closed")
153
170
  }
154
-
155
- while (!isClosed) {
156
- val data = client.readStreamData(streamId)
157
- if (data != null && data.isNotEmpty()) {
158
- return data
159
- }
160
- delay(5)
161
- }
162
- return ByteArray(0)
171
+ val data = client.readStreamData(streamId) ?: return ByteArray(0)
172
+ return data
163
173
  }
164
174
 
165
175
  override suspend fun write(data: ByteArray) {
@@ -36,7 +36,7 @@ class QuicClientStub(private val initialReadData: List<Byte> = emptyList()) : Qu
36
36
  private var buffer: MockStreamBuffer? = null
37
37
  private var streamId = 0L
38
38
 
39
- override suspend fun connect(host: String, port: Int) {
39
+ override suspend fun connect(host: String, port: Int, connectAddress: String?) {
40
40
  buffer = MockStreamBuffer(initialReadData.toByteArray())
41
41
  streamId = 0L
42
42
  }
@@ -15,7 +15,7 @@ interface QuicStream {
15
15
  * QUIC client: connect, TLS handshake, open one bidirectional stream.
16
16
  */
17
17
  interface QuicClient {
18
- suspend fun connect(host: String, port: Int)
18
+ suspend fun connect(host: String, port: Int, connectAddress: String? = null)
19
19
  suspend fun openStream(): QuicStream
20
20
  suspend fun close()
21
21
  }