@annadata/capacitor-mqtt-quic 0.1.7 → 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.
- package/AnnadataCapacitorMqttQuic.podspec +2 -2
- package/Package.swift +59 -0
- package/README.md +57 -17
- package/android/NGTCP2_BUILD_INSTRUCTIONS.md +1 -1
- package/android/app/src/main/assets/capacitor.config.json +1 -1
- package/android/build-wolfssl.sh +8 -2
- package/android/build.gradle +4 -1
- 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/CMakeLists.txt +19 -5
- package/android/src/main/cpp/ngtcp2_jni.cpp +119 -32
- package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +60 -5
- package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +233 -84
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +36 -5
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTTypes.kt +15 -15
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +26 -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/docs/IMPLEMENTATION_SUMMARY.md +1 -1
- package/docs/NGTCP2_IMPLEMENTATION_STATUS.md +1 -1
- package/docs/PRODUCTION_PUBLISH_STEPS.md +9 -3
- package/docs/diff-node_modules-vs-standalone-android-src.patch +1031 -0
- package/ios/{MqttQuicPlugin.podspec → AnnadataCapacitorMqttQuic.podspec} +4 -4
- package/ios/App/App/capacitor.config.json +1 -1
- package/ios/NGTCP2_BUILD_INSTRUCTIONS.md +3 -3
- package/ios/Package.swift +7 -8
- package/ios/Tests/MQTTProtocolTests.swift +1 -1
- 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 +5 -2
- 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,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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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 (
|
|
264
|
-
if (
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
}
|
|
290
|
-
|
|
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 (
|
|
417
|
+
val (w, version) = lock.withLock {
|
|
298
418
|
subscribedTopics.remove(topic)
|
|
299
|
-
|
|
419
|
+
writer to activeProtocolVersion
|
|
300
420
|
}
|
|
301
|
-
if (
|
|
421
|
+
if (w == null) throw IllegalStateException("no writer")
|
|
302
422
|
|
|
303
423
|
val pid = nextPacketIdUsed()
|
|
304
|
-
val
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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,12 +495,21 @@ 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()
|
|
375
|
-
val ka = lock.withLock { effectiveKeepalive }
|
|
376
|
-
if (ka <= 0) return
|
|
377
510
|
keepaliveJob = scope.launch {
|
|
511
|
+
val ka = lock.withLock { effectiveKeepalive }
|
|
512
|
+
if (ka <= 0) return@launch
|
|
378
513
|
while (isActive) {
|
|
379
514
|
delay(ka * 1000L) // seconds to ms
|
|
380
515
|
val (w, stillConnected) = lock.withLock {
|
|
@@ -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
|
|
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 -> {
|
|
@@ -428,8 +577,7 @@ class MQTTClient {
|
|
|
428
577
|
if (activeProtocolVersion == MQTTProtocolLevel.V5) {
|
|
429
578
|
MQTT5Protocol.parsePublishV5(rest, 0, qos, topicAliasMap)
|
|
430
579
|
} else {
|
|
431
|
-
|
|
432
|
-
Triple(t.first, t.second, t.third)
|
|
580
|
+
MQTTProtocol.parsePublish(rest, 0, qos)
|
|
433
581
|
}
|
|
434
582
|
}
|
|
435
583
|
|
|
@@ -474,6 +622,7 @@ class MQTTClient {
|
|
|
474
622
|
topicAliasMap.clear()
|
|
475
623
|
state = State.DISCONNECTED
|
|
476
624
|
}
|
|
625
|
+
failPendingSubacksUnsubacks(e)
|
|
477
626
|
}
|
|
478
627
|
break
|
|
479
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
|
|
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,
|
|
@@ -201,9 +232,9 @@ object MQTTProtocol {
|
|
|
201
232
|
|
|
202
233
|
/**
|
|
203
234
|
* Parse PUBLISH payload (after fixed header).
|
|
204
|
-
* Returns (topic, packetId?, payload
|
|
235
|
+
* Returns (topic, packetId?, payload). packetId only for QoS > 0.
|
|
205
236
|
*/
|
|
206
|
-
fun parsePublish(data: ByteArray, offset: Int, qos: Int): Triple<String, Int?, ByteArray
|
|
237
|
+
fun parsePublish(data: ByteArray, offset: Int, qos: Int): Triple<String, Int?, ByteArray> {
|
|
207
238
|
var off = offset
|
|
208
239
|
val (topic, next) = decodeString(data, off)
|
|
209
240
|
off = next
|
|
@@ -214,7 +245,7 @@ object MQTTProtocol {
|
|
|
214
245
|
off += 2
|
|
215
246
|
}
|
|
216
247
|
val payload = data.copyOfRange(off, data.size)
|
|
217
|
-
return Triple(topic, pid, payload
|
|
248
|
+
return Triple(topic, pid, payload)
|
|
218
249
|
}
|
|
219
250
|
|
|
220
251
|
fun buildSubscribe(packetId: Int, topic: String, qos: Int = 0): ByteArray {
|
|
@@ -5,21 +5,21 @@ package ai.annadata.mqttquic.mqtt
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
object MQTTMessageType {
|
|
8
|
-
const val CONNECT: Byte = 0x10
|
|
9
|
-
const val CONNACK: Byte = 0x20
|
|
10
|
-
const val PUBLISH: Byte = 0x30
|
|
11
|
-
const val PUBACK: Byte = 0x40
|
|
12
|
-
const val PUBREC: Byte = 0x50
|
|
13
|
-
const val PUBREL: Byte = 0x62
|
|
14
|
-
const val PUBCOMP: Byte = 0x70
|
|
15
|
-
const val SUBSCRIBE: Byte = 0x82
|
|
16
|
-
const val SUBACK: Byte = 0x90
|
|
17
|
-
const val UNSUBSCRIBE: Byte = 0xA2
|
|
18
|
-
const val UNSUBACK: Byte = 0xB0
|
|
19
|
-
const val PINGREQ: Byte = 0xC0
|
|
20
|
-
const val PINGRESP: Byte = 0xD0
|
|
21
|
-
const val DISCONNECT: Byte = 0xE0
|
|
22
|
-
const val AUTH: Byte = 0xF0
|
|
8
|
+
const val CONNECT: Byte = 0x10.toByte()
|
|
9
|
+
const val CONNACK: Byte = 0x20.toByte()
|
|
10
|
+
const val PUBLISH: Byte = 0x30.toByte()
|
|
11
|
+
const val PUBACK: Byte = 0x40.toByte()
|
|
12
|
+
const val PUBREC: Byte = 0x50.toByte()
|
|
13
|
+
const val PUBREL: Byte = 0x62.toByte()
|
|
14
|
+
const val PUBCOMP: Byte = 0x70.toByte()
|
|
15
|
+
const val SUBSCRIBE: Byte = 0x82.toByte()
|
|
16
|
+
const val SUBACK: Byte = 0x90.toByte()
|
|
17
|
+
const val UNSUBSCRIBE: Byte = 0xA2.toByte()
|
|
18
|
+
const val UNSUBACK: Byte = 0xB0.toByte()
|
|
19
|
+
const val PINGREQ: Byte = 0xC0.toByte()
|
|
20
|
+
const val PINGRESP: Byte = 0xD0.toByte()
|
|
21
|
+
const val DISCONNECT: Byte = 0xE0.toByte()
|
|
22
|
+
const val AUTH: Byte = 0xF0.toByte()
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
object MQTTConnectFlags {
|
|
@@ -40,21 +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
|
-
|
|
51
|
-
|
|
50
|
+
// Public to avoid Kotlin internal-name mangling (nativeCloseStream$module) which breaks JNI lookup
|
|
51
|
+
external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
|
|
52
|
+
@JvmName("nativeGetLastError")
|
|
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
|
+
|
|
52
59
|
// Connection state
|
|
53
60
|
private var connHandle: Long = 0
|
|
54
61
|
private var isConnected: Boolean = false
|
|
55
62
|
private val streams = mutableMapOf<Long, NGTCP2Stream>()
|
|
56
63
|
|
|
57
|
-
override suspend fun connect(host: String, port: Int) {
|
|
64
|
+
override suspend fun connect(host: String, port: Int, connectAddress: String?) {
|
|
58
65
|
if (!isAvailable()) {
|
|
59
66
|
throw IllegalStateException("ngtcp2 native library is not loaded")
|
|
60
67
|
}
|
|
@@ -62,8 +69,12 @@ class NGTCP2Client : QuicClient {
|
|
|
62
69
|
throw IllegalStateException("Already connected")
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
// Create native connection
|
|
66
|
-
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
|
+
}
|
|
67
78
|
if (connHandle == 0L) {
|
|
68
79
|
throw IllegalStateException("Failed to create QUIC connection")
|
|
69
80
|
}
|
|
@@ -146,19 +157,19 @@ internal class NGTCP2Stream(
|
|
|
146
157
|
|
|
147
158
|
private var isClosed: Boolean = false
|
|
148
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
|
+
*/
|
|
149
167
|
override suspend fun read(maxBytes: Int): ByteArray {
|
|
150
168
|
if (isClosed) {
|
|
151
169
|
throw IllegalStateException("Stream is closed")
|
|
152
170
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
val data = client.readStreamData(streamId)
|
|
156
|
-
if (data != null && data.isNotEmpty()) {
|
|
157
|
-
return data
|
|
158
|
-
}
|
|
159
|
-
delay(5)
|
|
160
|
-
}
|
|
161
|
-
return ByteArray(0)
|
|
171
|
+
val data = client.readStreamData(streamId) ?: return ByteArray(0)
|
|
172
|
+
return data
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
override suspend fun write(data: ByteArray) {
|