@annadata/capacitor-mqtt-quic 0.1.8 → 0.2.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.
- package/README.md +90 -9
- package/android/build-wolfssl.sh +8 -2
- package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.a +0 -0
- package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.so +0 -0
- package/android/install/nghttp3-android/arm64-v8a/lib/pkgconfig/libnghttp3.pc +4 -4
- package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.a +0 -0
- package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.so +0 -0
- package/android/install/nghttp3-android/armeabi-v7a/lib/pkgconfig/libnghttp3.pc +4 -4
- package/android/install/nghttp3-android/x86_64/lib/libnghttp3.a +0 -0
- package/android/install/nghttp3-android/x86_64/lib/libnghttp3.so +0 -0
- package/android/install/nghttp3-android/x86_64/lib/pkgconfig/libnghttp3.pc +4 -4
- package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.a +0 -0
- package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.so +0 -0
- package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.a +0 -0
- package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.so +0 -0
- package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2.pc +4 -4
- package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
- package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.a +0 -0
- package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.so +0 -0
- package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.a +0 -0
- package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.so +0 -0
- package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2.pc +4 -4
- package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
- package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.a +0 -0
- package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.so +0 -0
- package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.a +0 -0
- package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.so +0 -0
- package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2.pc +4 -4
- package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
- package/android/install/wolfssl-android/arm64-v8a/bin/wolfssl-config +1 -1
- package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.a +0 -0
- package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.la +1 -1
- package/android/install/wolfssl-android/arm64-v8a/lib/pkgconfig/wolfssl.pc +1 -1
- package/android/install/wolfssl-android/armeabi-v7a/bin/wolfssl-config +1 -1
- package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.a +0 -0
- package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.la +1 -1
- package/android/install/wolfssl-android/armeabi-v7a/lib/pkgconfig/wolfssl.pc +1 -1
- package/android/install/wolfssl-android/x86_64/bin/wolfssl-config +1 -1
- package/android/install/wolfssl-android/x86_64/lib/libwolfssl.a +0 -0
- package/android/install/wolfssl-android/x86_64/lib/libwolfssl.la +1 -1
- package/android/install/wolfssl-android/x86_64/lib/pkgconfig/wolfssl.pc +1 -1
- package/android/src/main/cpp/ngtcp2_jni.cpp +94 -19
- package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +75 -3
- package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +270 -80
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +33 -2
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +25 -15
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +1 -1
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +1 -1
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +80 -5
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +4 -0
- package/dist/esm/definitions.d.ts +8 -0
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/web.d.ts +5 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +6 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +6 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +6 -0
- package/dist/plugin.js.map +1 -1
- package/docs/diff-node_modules-vs-standalone-android-src.patch +1031 -0
- package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +4 -0
- package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +14 -8
- package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +2 -7
- package/ios/build-wolfssl.sh +8 -3
- package/ios/libs/libnghttp3.a +0 -0
- package/ios/libs/libngtcp2.a +0 -0
- package/ios/libs/libngtcp2_crypto_wolfssl.a +0 -0
- package/ios/libs/libwolfssl.a +0 -0
- package/ios/libs-simulator/libnghttp3.a +0 -0
- package/ios/libs-simulator/libngtcp2.a +0 -0
- package/ios/libs-simulator/libngtcp2_crypto_wolfssl.a +0 -0
- package/ios/libs-simulator/libwolfssl.a +0 -0
- package/ios/libs-simulator-x86_64/libnghttp3.a +0 -0
- package/ios/libs-simulator-x86_64/libngtcp2.a +0 -0
- package/ios/libs-simulator-x86_64/libngtcp2_crypto_wolfssl.a +0 -0
- package/ios/libs-simulator-x86_64/libwolfssl.a +0 -0
- package/package.json +1 -1
- package/ios/libs/MqttQuicLibs.xcframework/Info.plist +0 -44
- package/ios/libs/MqttQuicLibs.xcframework/ios-arm64/libmqttquic_native_device.a +0 -0
- 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,13 @@ 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>>()
|
|
74
|
+
/** Pending PINGRESP for sendMqttPing(). Message loop completes when PINGRESP is read. */
|
|
75
|
+
@Volatile
|
|
76
|
+
private var pendingPingresp: CompletableDeferred<Unit>? = null
|
|
65
77
|
private val lock = Mutex()
|
|
66
78
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
|
67
79
|
|
|
@@ -77,6 +89,29 @@ class MQTTClient {
|
|
|
77
89
|
/** Assigned Client Identifier from CONNACK when client sent empty ClientID; null otherwise. */
|
|
78
90
|
fun getAssignedClientIdentifier(): String? = runBlocking { lock.withLock { assignedClientIdentifier } }
|
|
79
91
|
|
|
92
|
+
/** Resolved IP used for the current/last QUIC connection (from native getaddrinfo). Used by plugin to cache for reconnect when Java DNS fails. */
|
|
93
|
+
fun getLastResolvedAddress(): String? = (quicClient as? NGTCP2Client)?.getLastResolvedAddress()
|
|
94
|
+
|
|
95
|
+
/** Read full MQTT fixed header (1 byte type + 1–4 bytes remaining length per MQTT v5.0 §2.1.4). Returns (msgType, remLen, fixedHeaderBytes). */
|
|
96
|
+
private suspend fun readFixedHeader(r: MQTTStreamReader): Triple<Byte, Int, ByteArray> {
|
|
97
|
+
Log.i("MQTTClient", "readFixedHeader: requesting first byte")
|
|
98
|
+
var fixed = r.readexactly(1).toMutableList()
|
|
99
|
+
val firstByte = fixed[0].toInt() and 0xFF
|
|
100
|
+
Log.i("MQTTClient", "readFixedHeader: got type byte 0x${Integer.toHexString(firstByte)} ($firstByte)")
|
|
101
|
+
repeat(5) { // 1 type + up to 4 remaining-length bytes (Variable Byte Integer)
|
|
102
|
+
try {
|
|
103
|
+
val (rem, _) = MQTTProtocol.decodeRemainingLength(fixed.toByteArray(), 1)
|
|
104
|
+
Log.i("MQTTClient", "readFixedHeader: decoded remLen=$rem fixedSize=${fixed.size}")
|
|
105
|
+
return Triple(fixed[0], rem, fixed.toByteArray())
|
|
106
|
+
} catch (_: IllegalArgumentException) {
|
|
107
|
+
if (fixed.size > 5) throw IllegalArgumentException("Invalid remaining length")
|
|
108
|
+
fixed.addAll(r.readexactly(1).toList())
|
|
109
|
+
Log.i("MQTTClient", "readFixedHeader: added byte, fixedSize=${fixed.size}")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw IllegalArgumentException("Invalid remaining length")
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
suspend fun connect(
|
|
81
116
|
host: String,
|
|
82
117
|
port: Int,
|
|
@@ -85,7 +120,8 @@ class MQTTClient {
|
|
|
85
120
|
password: String?,
|
|
86
121
|
cleanSession: Boolean,
|
|
87
122
|
keepalive: Int,
|
|
88
|
-
sessionExpiryInterval: Int? = null
|
|
123
|
+
sessionExpiryInterval: Int? = null,
|
|
124
|
+
connectAddress: String? = null
|
|
89
125
|
) {
|
|
90
126
|
lock.withLock {
|
|
91
127
|
if (state == State.CONNECTING) {
|
|
@@ -109,7 +145,7 @@ class MQTTClient {
|
|
|
109
145
|
} else {
|
|
110
146
|
QuicClientStub(connack.toList())
|
|
111
147
|
}
|
|
112
|
-
quic.connect(host, port)
|
|
148
|
+
quic.connect(host, port, connectAddress)
|
|
113
149
|
val s = quic.openStream()
|
|
114
150
|
val r = QUICStreamReader(s)
|
|
115
151
|
val w = QUICStreamWriter(s)
|
|
@@ -146,50 +182,118 @@ class MQTTClient {
|
|
|
146
182
|
w.write(connectData)
|
|
147
183
|
w.drain()
|
|
148
184
|
|
|
149
|
-
// MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK
|
|
185
|
+
// MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK. Timeout so we don't block 30s until ERR_IDLE_CLOSE.
|
|
186
|
+
// Efficient path: drain stream (read until empty), then parse first complete packet; repeat until CONNACK or timeout.
|
|
150
187
|
var full: ByteArray
|
|
151
188
|
var hdrLen: Int
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
val connackTimeoutMs = 15_000L
|
|
190
|
+
try {
|
|
191
|
+
if (activeProtocolVersion == MQTTProtocolLevel.V5) {
|
|
192
|
+
Log.i("MQTTClient", "reading CONNACK (MQTT5) timeout=${connackTimeoutMs}ms (drain then parse)")
|
|
193
|
+
withTimeout(connackTimeoutMs) {
|
|
194
|
+
while (true) {
|
|
195
|
+
if (r is QUICStreamReader) {
|
|
196
|
+
r.drain()
|
|
197
|
+
val avail = r.available()
|
|
198
|
+
Log.i("MQTTClient", "CONNACK loop: after drain available=$avail")
|
|
199
|
+
val packet = r.tryConsumeNextPacket()
|
|
200
|
+
if (packet != null) {
|
|
201
|
+
val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
|
|
202
|
+
val typeByte = msgType.toInt() and 0xFF
|
|
203
|
+
Log.i("MQTTClient", "CONNACK loop: packet type=0x${Integer.toHexString(typeByte)} len=${packet.size} hdrLen=$fixedLen")
|
|
204
|
+
when (typeByte) {
|
|
205
|
+
0x20 -> {
|
|
206
|
+
full = packet
|
|
207
|
+
hdrLen = fixedLen
|
|
208
|
+
return@withTimeout
|
|
209
|
+
}
|
|
210
|
+
0xF0 -> {
|
|
211
|
+
lock.withLock { state = State.ERROR }
|
|
212
|
+
try {
|
|
213
|
+
w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
|
|
214
|
+
w.drain()
|
|
215
|
+
w.close()
|
|
216
|
+
} catch (_: Exception) { /* ignore */ }
|
|
217
|
+
throw IllegalArgumentException("Enhanced authentication not supported")
|
|
218
|
+
}
|
|
219
|
+
0xE0 -> {
|
|
220
|
+
lock.withLock { state = State.ERROR }
|
|
221
|
+
throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
|
|
222
|
+
}
|
|
223
|
+
else -> Log.i("MQTTClient", "CONNACK loop: skipping non-CONNACK packet type=0x${Integer.toHexString(typeByte)}")
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
// Only delay when no data; if we have data but no packet, retry soon (avoid suspend then timeout before next iteration)
|
|
227
|
+
if (avail == 0) delay(50) else delay(10)
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
// Fallback for non-QUIC reader (e.g. mock)
|
|
231
|
+
val (msgType, remLen, fixed) = readFixedHeader(r)
|
|
232
|
+
val typeByte = fixed[0].toInt() and 0xFF
|
|
233
|
+
val rest = r.readexactly(remLen)
|
|
234
|
+
full = fixed + rest
|
|
235
|
+
hdrLen = fixed.size
|
|
236
|
+
when (typeByte) {
|
|
237
|
+
0x20 -> return@withTimeout
|
|
238
|
+
0xF0 -> {
|
|
239
|
+
lock.withLock { state = State.ERROR }
|
|
240
|
+
try {
|
|
241
|
+
w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
|
|
242
|
+
w.drain()
|
|
243
|
+
w.close()
|
|
244
|
+
} catch (_: Exception) { /* ignore */ }
|
|
245
|
+
throw IllegalArgumentException("Enhanced authentication not supported")
|
|
246
|
+
}
|
|
247
|
+
0xE0 -> {
|
|
248
|
+
lock.withLock { state = State.ERROR }
|
|
249
|
+
throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
|
|
250
|
+
}
|
|
251
|
+
else -> { /* skip; continue */ }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
173
254
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
255
|
+
}
|
|
256
|
+
Log.i("MQTTClient", "got CONNACK (MQTT5) (fullLen=${full.size} hdrLen=$hdrLen)")
|
|
257
|
+
} else {
|
|
258
|
+
Log.i("MQTTClient", "reading CONNACK (3.1.1) timeout=${connackTimeoutMs}ms")
|
|
259
|
+
withTimeout(connackTimeoutMs) {
|
|
260
|
+
if (r is QUICStreamReader) {
|
|
261
|
+
while (true) {
|
|
262
|
+
r.drain()
|
|
263
|
+
val packet = r.tryConsumeNextPacket()
|
|
264
|
+
if (packet != null) {
|
|
265
|
+
val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
|
|
266
|
+
if (msgType == MQTTMessageType.CONNACK) {
|
|
267
|
+
full = packet
|
|
268
|
+
hdrLen = fixedLen
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
lock.withLock { state = State.ERROR }
|
|
272
|
+
throw IllegalArgumentException("expected CONNACK, got $msgType")
|
|
273
|
+
}
|
|
274
|
+
delay(50)
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
val (msgType, remLen, fixed) = readFixedHeader(r)
|
|
278
|
+
val rest = r.readexactly(remLen)
|
|
279
|
+
full = fixed + rest
|
|
280
|
+
hdrLen = fixed.size
|
|
281
|
+
if (msgType != MQTTMessageType.CONNACK) {
|
|
282
|
+
lock.withLock { state = State.ERROR }
|
|
283
|
+
throw IllegalArgumentException("expected CONNACK, got $msgType")
|
|
284
|
+
}
|
|
177
285
|
}
|
|
286
|
+
Log.i("MQTTClient", "got CONNACK (3.1.1)")
|
|
178
287
|
}
|
|
179
288
|
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
}
|
|
289
|
+
} catch (e: TimeoutCancellationException) {
|
|
290
|
+
lock.withLock { state = State.ERROR }
|
|
291
|
+
Log.w("MQTTClient", "CONNACK read timed out after ${connackTimeoutMs}ms", e)
|
|
292
|
+
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
293
|
}
|
|
191
294
|
|
|
192
295
|
if (activeProtocolVersion == MQTTProtocolLevel.V5) {
|
|
296
|
+
Log.i("MQTTClient", "parsing CONNACK v5 (offset=$hdrLen)")
|
|
193
297
|
val (_, reasonCode, props) = MQTT5Protocol.parseConnackV5(full, hdrLen)
|
|
194
298
|
if (reasonCode != MQTT5ReasonCode.SUCCESS) {
|
|
195
299
|
lock.withLock { state = State.ERROR }
|
|
@@ -210,21 +314,37 @@ class MQTTClient {
|
|
|
210
314
|
}
|
|
211
315
|
|
|
212
316
|
lock.withLock { state = State.CONNECTED }
|
|
317
|
+
Log.i("MQTTClient", "state=CONNECTED, starting message and keepalive loops")
|
|
213
318
|
startMessageLoop()
|
|
214
319
|
startKeepaliveLoop()
|
|
215
320
|
} catch (e: Exception) {
|
|
216
|
-
val wr = lock.withLock {
|
|
321
|
+
val (wr, quic) = lock.withLock {
|
|
217
322
|
val w = writer
|
|
323
|
+
val q = quicClient
|
|
218
324
|
quicClient = null
|
|
219
325
|
stream = null
|
|
220
326
|
reader = null
|
|
221
327
|
writer = null
|
|
222
328
|
state = State.ERROR
|
|
223
|
-
w
|
|
329
|
+
Pair(w, q)
|
|
224
330
|
}
|
|
225
331
|
try {
|
|
226
332
|
wr?.close()
|
|
227
333
|
} catch (_: Exception) { /* ignore */ }
|
|
334
|
+
// Skip quic.close() on timeout/cancellation: server may have already sent idle close, and native close() can crash. Prefer leak over crash.
|
|
335
|
+
val isTimeoutOrCancel = e is TimeoutCancellationException ||
|
|
336
|
+
e is CancellationException ||
|
|
337
|
+
e.cause is TimeoutCancellationException ||
|
|
338
|
+
(e.message?.contains("Timed out", ignoreCase = true) == true)
|
|
339
|
+
if (!isTimeoutOrCancel) {
|
|
340
|
+
try {
|
|
341
|
+
quic?.close()
|
|
342
|
+
} catch (ex: Exception) {
|
|
343
|
+
Log.w("MQTTClient", "Error closing QUIC connection on connect failure", ex)
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
Log.i("MQTTClient", "Skipping quic.close() on timeout/cancellation to avoid native crash")
|
|
347
|
+
}
|
|
228
348
|
throw e
|
|
229
349
|
}
|
|
230
350
|
}
|
|
@@ -260,59 +380,66 @@ class MQTTClient {
|
|
|
260
380
|
|
|
261
381
|
suspend fun subscribe(topic: String, qos: Int, subscriptionIdentifier: Int? = null) {
|
|
262
382
|
if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
|
|
263
|
-
val (
|
|
264
|
-
if (
|
|
383
|
+
val (w, version) = lock.withLock { writer to activeProtocolVersion }
|
|
384
|
+
if (w == null) throw IllegalStateException("no writer")
|
|
265
385
|
|
|
266
386
|
val pid = nextPacketIdUsed()
|
|
267
|
-
val
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
387
|
+
val deferred = CompletableDeferred<Pair<ByteArray, Int>>()
|
|
388
|
+
lock.withLock { pendingSubacks[pid] = deferred }
|
|
389
|
+
try {
|
|
390
|
+
val data: ByteArray
|
|
391
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
392
|
+
data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
|
|
393
|
+
} else {
|
|
394
|
+
data = MQTTProtocol.buildSubscribe(pid, topic, qos)
|
|
395
|
+
}
|
|
396
|
+
w.write(data)
|
|
397
|
+
w.drain()
|
|
398
|
+
|
|
399
|
+
val (full, hdrLen) = withTimeout(15_000L) { deferred.await() }
|
|
400
|
+
|
|
401
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
402
|
+
val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
|
|
403
|
+
if (reasonCodes.isNotEmpty()) {
|
|
404
|
+
val firstRC = reasonCodes[0]
|
|
405
|
+
if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
|
|
406
|
+
throw IllegalArgumentException("SUBACK error $firstRC")
|
|
407
|
+
}
|
|
287
408
|
}
|
|
409
|
+
} else {
|
|
410
|
+
val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
|
|
411
|
+
if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
|
|
288
412
|
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
|
|
413
|
+
} finally {
|
|
414
|
+
lock.withLock { pendingSubacks.remove(pid) }
|
|
292
415
|
}
|
|
293
416
|
}
|
|
294
417
|
|
|
295
418
|
suspend fun unsubscribe(topic: String) {
|
|
296
419
|
if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
|
|
297
|
-
val (
|
|
420
|
+
val (w, version) = lock.withLock {
|
|
298
421
|
subscribedTopics.remove(topic)
|
|
299
|
-
|
|
422
|
+
writer to activeProtocolVersion
|
|
300
423
|
}
|
|
301
|
-
if (
|
|
424
|
+
if (w == null) throw IllegalStateException("no writer")
|
|
302
425
|
|
|
303
426
|
val pid = nextPacketIdUsed()
|
|
304
|
-
val
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
427
|
+
val deferred = CompletableDeferred<Unit>()
|
|
428
|
+
lock.withLock { pendingUnsubacks[pid] = deferred }
|
|
429
|
+
try {
|
|
430
|
+
val data: ByteArray
|
|
431
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
432
|
+
data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
|
|
433
|
+
} else {
|
|
434
|
+
data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
|
|
435
|
+
}
|
|
436
|
+
w.write(data)
|
|
437
|
+
w.drain()
|
|
312
438
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
439
|
+
withTimeout(15_000L) { deferred.await() }
|
|
440
|
+
} finally {
|
|
441
|
+
lock.withLock { pendingUnsubacks.remove(pid) }
|
|
442
|
+
}
|
|
316
443
|
}
|
|
317
444
|
|
|
318
445
|
suspend fun disconnect() {
|
|
@@ -323,6 +450,8 @@ class MQTTClient {
|
|
|
323
450
|
job?.cancel()
|
|
324
451
|
job?.join()
|
|
325
452
|
|
|
453
|
+
failPendingSubacksUnsubacks(IllegalStateException("Disconnected"))
|
|
454
|
+
|
|
326
455
|
val (w, version) = lock.withLock {
|
|
327
456
|
val wr = writer
|
|
328
457
|
val v = activeProtocolVersion
|
|
@@ -369,6 +498,46 @@ class MQTTClient {
|
|
|
369
498
|
pid
|
|
370
499
|
}
|
|
371
500
|
|
|
501
|
+
private suspend fun failPendingSubacksUnsubacks(cause: Throwable) {
|
|
502
|
+
lock.withLock {
|
|
503
|
+
pendingSubacks.values.forEach { it.completeExceptionally(cause) }
|
|
504
|
+
pendingSubacks.clear()
|
|
505
|
+
pendingUnsubacks.values.forEach { it.completeExceptionally(cause) }
|
|
506
|
+
pendingUnsubacks.clear()
|
|
507
|
+
pendingPingresp?.completeExceptionally(cause)
|
|
508
|
+
pendingPingresp = null
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Send MQTT PINGREQ and wait for PINGRESP. Resets server's idle timer.
|
|
514
|
+
* Returns true if PINGRESP received within timeout, false on timeout or error.
|
|
515
|
+
*/
|
|
516
|
+
suspend fun sendMqttPing(timeoutMs: Long = 5000): Boolean {
|
|
517
|
+
if (getState() != State.CONNECTED) return false
|
|
518
|
+
val (w, defToAwait) = lock.withLock {
|
|
519
|
+
val existing = pendingPingresp
|
|
520
|
+
if (existing != null) return@withLock (null as MQTTStreamWriter?) to existing
|
|
521
|
+
val deferred = CompletableDeferred<Unit>()
|
|
522
|
+
pendingPingresp = deferred
|
|
523
|
+
writer to deferred
|
|
524
|
+
}
|
|
525
|
+
return try {
|
|
526
|
+
if (w != null) {
|
|
527
|
+
w.write(MQTTProtocol.buildPingreq())
|
|
528
|
+
w.drain()
|
|
529
|
+
}
|
|
530
|
+
if (defToAwait != null) withTimeout(timeoutMs) { defToAwait.await() }
|
|
531
|
+
true
|
|
532
|
+
} catch (e: Exception) {
|
|
533
|
+
lock.withLock {
|
|
534
|
+
pendingPingresp?.completeExceptionally(e)
|
|
535
|
+
pendingPingresp = null
|
|
536
|
+
}
|
|
537
|
+
false
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
372
541
|
/** Send PINGREQ at effectiveKeepalive interval so server sees activity and does not close (idle/keepalive). [MQTT-3.1.2-20] */
|
|
373
542
|
private fun startKeepaliveLoop() {
|
|
374
543
|
keepaliveJob?.cancel()
|
|
@@ -394,11 +563,24 @@ class MQTTClient {
|
|
|
394
563
|
while (isActive) {
|
|
395
564
|
val r = lock.withLock { reader } ?: break
|
|
396
565
|
try {
|
|
397
|
-
val fixed = r
|
|
398
|
-
val (msgType, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
|
|
566
|
+
val (msgType, remLen, fixed) = readFixedHeader(r)
|
|
399
567
|
val rest = r.readexactly(remLen)
|
|
400
568
|
val type = (msgType.toInt() and 0xF0).toByte()
|
|
401
569
|
when (type) {
|
|
570
|
+
MQTTMessageType.SUBACK -> {
|
|
571
|
+
if (rest.size >= 2) {
|
|
572
|
+
val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
|
|
573
|
+
val full = fixed + rest
|
|
574
|
+
val hdrLen = fixed.size
|
|
575
|
+
lock.withLock { pendingSubacks.remove(pid)?.complete(Pair(full, hdrLen)) }
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
MQTTMessageType.UNSUBACK -> {
|
|
579
|
+
if (rest.size >= 2) {
|
|
580
|
+
val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
|
|
581
|
+
lock.withLock { pendingUnsubacks.remove(pid)?.complete(Unit) }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
402
584
|
MQTTMessageType.DISCONNECT -> {
|
|
403
585
|
val reasonCode = if (rest.isNotEmpty()) rest[0].toInt() and 0xFF else 0x00
|
|
404
586
|
lock.withLock {
|
|
@@ -412,6 +594,7 @@ class MQTTClient {
|
|
|
412
594
|
topicAliasMap.clear()
|
|
413
595
|
state = if (reasonCode >= 0x80) State.ERROR else State.DISCONNECTED
|
|
414
596
|
}
|
|
597
|
+
failPendingSubacksUnsubacks(IllegalStateException("Server sent DISCONNECT"))
|
|
415
598
|
break
|
|
416
599
|
}
|
|
417
600
|
MQTTMessageType.PINGREQ -> {
|
|
@@ -422,6 +605,12 @@ class MQTTClient {
|
|
|
422
605
|
it.drain()
|
|
423
606
|
}
|
|
424
607
|
}
|
|
608
|
+
MQTTMessageType.PINGRESP -> {
|
|
609
|
+
lock.withLock {
|
|
610
|
+
pendingPingresp?.complete(Unit)
|
|
611
|
+
pendingPingresp = null
|
|
612
|
+
}
|
|
613
|
+
}
|
|
425
614
|
MQTTMessageType.PUBLISH -> {
|
|
426
615
|
val qos = (msgType.toInt() shr 1) and 0x03
|
|
427
616
|
val (topic, packetId, payload) = lock.withLock {
|
|
@@ -473,6 +662,7 @@ class MQTTClient {
|
|
|
473
662
|
topicAliasMap.clear()
|
|
474
663
|
state = State.DISCONNECTED
|
|
475
664
|
}
|
|
665
|
+
failPendingSubacksUnsubacks(e)
|
|
476
666
|
}
|
|
477
667
|
break
|
|
478
668
|
}
|
|
@@ -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
|
|
43
|
+
if ((b and 0x80) == 0) return len to (i - offset)
|
|
44
44
|
mul *= 128
|
|
45
45
|
}
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|