@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.
Files changed (81) hide show
  1. package/README.md +90 -9
  2. package/android/build-wolfssl.sh +8 -2
  3. package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.a +0 -0
  4. package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.so +0 -0
  5. package/android/install/nghttp3-android/arm64-v8a/lib/pkgconfig/libnghttp3.pc +4 -4
  6. package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.a +0 -0
  7. package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.so +0 -0
  8. package/android/install/nghttp3-android/armeabi-v7a/lib/pkgconfig/libnghttp3.pc +4 -4
  9. package/android/install/nghttp3-android/x86_64/lib/libnghttp3.a +0 -0
  10. package/android/install/nghttp3-android/x86_64/lib/libnghttp3.so +0 -0
  11. package/android/install/nghttp3-android/x86_64/lib/pkgconfig/libnghttp3.pc +4 -4
  12. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.a +0 -0
  13. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.so +0 -0
  14. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.a +0 -0
  15. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.so +0 -0
  16. package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2.pc +4 -4
  17. package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  18. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.a +0 -0
  19. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.so +0 -0
  20. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.a +0 -0
  21. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.so +0 -0
  22. package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2.pc +4 -4
  23. package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  24. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.a +0 -0
  25. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.so +0 -0
  26. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.a +0 -0
  27. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.so +0 -0
  28. package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2.pc +4 -4
  29. package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  30. package/android/install/wolfssl-android/arm64-v8a/bin/wolfssl-config +1 -1
  31. package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.a +0 -0
  32. package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.la +1 -1
  33. package/android/install/wolfssl-android/arm64-v8a/lib/pkgconfig/wolfssl.pc +1 -1
  34. package/android/install/wolfssl-android/armeabi-v7a/bin/wolfssl-config +1 -1
  35. package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.a +0 -0
  36. package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.la +1 -1
  37. package/android/install/wolfssl-android/armeabi-v7a/lib/pkgconfig/wolfssl.pc +1 -1
  38. package/android/install/wolfssl-android/x86_64/bin/wolfssl-config +1 -1
  39. package/android/install/wolfssl-android/x86_64/lib/libwolfssl.a +0 -0
  40. package/android/install/wolfssl-android/x86_64/lib/libwolfssl.la +1 -1
  41. package/android/install/wolfssl-android/x86_64/lib/pkgconfig/wolfssl.pc +1 -1
  42. package/android/src/main/cpp/ngtcp2_jni.cpp +94 -19
  43. package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +75 -3
  44. package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +270 -80
  45. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +33 -2
  46. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +25 -15
  47. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +1 -1
  48. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +1 -1
  49. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +80 -5
  50. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +4 -0
  51. package/dist/esm/definitions.d.ts +8 -0
  52. package/dist/esm/definitions.d.ts.map +1 -1
  53. package/dist/esm/web.d.ts +5 -1
  54. package/dist/esm/web.d.ts.map +1 -1
  55. package/dist/esm/web.js +6 -0
  56. package/dist/esm/web.js.map +1 -1
  57. package/dist/plugin.cjs.js +6 -0
  58. package/dist/plugin.cjs.js.map +1 -1
  59. package/dist/plugin.js +6 -0
  60. package/dist/plugin.js.map +1 -1
  61. package/docs/diff-node_modules-vs-standalone-android-src.patch +1031 -0
  62. package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +4 -0
  63. package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +14 -8
  64. package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +2 -7
  65. package/ios/build-wolfssl.sh +8 -3
  66. package/ios/libs/libnghttp3.a +0 -0
  67. package/ios/libs/libngtcp2.a +0 -0
  68. package/ios/libs/libngtcp2_crypto_wolfssl.a +0 -0
  69. package/ios/libs/libwolfssl.a +0 -0
  70. package/ios/libs-simulator/libnghttp3.a +0 -0
  71. package/ios/libs-simulator/libngtcp2.a +0 -0
  72. package/ios/libs-simulator/libngtcp2_crypto_wolfssl.a +0 -0
  73. package/ios/libs-simulator/libwolfssl.a +0 -0
  74. package/ios/libs-simulator-x86_64/libnghttp3.a +0 -0
  75. package/ios/libs-simulator-x86_64/libngtcp2.a +0 -0
  76. package/ios/libs-simulator-x86_64/libngtcp2_crypto_wolfssl.a +0 -0
  77. package/ios/libs-simulator-x86_64/libwolfssl.a +0 -0
  78. package/package.json +1 -1
  79. package/ios/libs/MqttQuicLibs.xcframework/Info.plist +0 -44
  80. package/ios/libs/MqttQuicLibs.xcframework/ios-arm64/libmqttquic_native_device.a +0 -0
  81. package/ios/libs/MqttQuicLibs.xcframework/ios-arm64_x86_64-simulator/libmqttquic_native_simulator.a +0 -0
@@ -0,0 +1,1031 @@
1
+ diff -Naur capacitor-mqtt-quic/android/src/main/cpp/ngtcp2_jni.cpp annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/cpp/ngtcp2_jni.cpp
2
+ --- capacitor-mqtt-quic/android/src/main/cpp/ngtcp2_jni.cpp 2026-02-18 22:28:55
3
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/cpp/ngtcp2_jni.cpp 2026-02-19 10:27:58
4
+ @@ -24,6 +24,7 @@
5
+ #include <algorithm>
6
+ #include <atomic>
7
+ #include <chrono>
8
+ +#include <cinttypes>
9
+ #include <cstdarg>
10
+ #include <cstdlib>
11
+ #include <cstdio>
12
+ @@ -73,7 +74,11 @@
13
+ class QuicClient {
14
+ public:
15
+ QuicClient(std::string host, uint16_t port)
16
+ - : host_(std::move(host)),
17
+ + : QuicClient(std::move(host), "", port) {}
18
+ +
19
+ + QuicClient(std::string host_for_tls, std::string connect_addr, uint16_t port)
20
+ + : host_(std::move(host_for_tls)),
21
+ + connect_addr_(connect_addr.empty() ? host_ : std::move(connect_addr)),
22
+ port_(port),
23
+ fd_(-1),
24
+ ssl_ctx_(nullptr),
25
+ @@ -181,6 +186,9 @@
26
+ buffer[i] = state.recv_buf.front();
27
+ state.recv_buf.pop_front();
28
+ }
29
+ + if (n > 0) {
30
+ + LOGI("read_stream stream_id=%" PRId64 " returning %zu bytes", (int64_t)stream_id, n);
31
+ + }
32
+ return (ssize_t)n;
33
+ }
34
+
35
+ @@ -223,6 +231,8 @@
36
+ std::lock_guard<std::mutex> lock(stream_mutex_);
37
+ StreamState &state = streams_[stream_id];
38
+ state.recv_buf.insert(state.recv_buf.end(), data, data + datalen);
39
+ + LOGI("recv stream data stream_id=%" PRId64 " len=%zu recv_buf_total=%zu",
40
+ + (int64_t)stream_id, datalen, state.recv_buf.size());
41
+ if (flags & NGTCP2_STREAM_DATA_FLAG_FIN) {
42
+ state.fin_received = true;
43
+ }
44
+ @@ -254,7 +264,8 @@
45
+
46
+ char port_str[16];
47
+ snprintf(port_str, sizeof(port_str), "%u", port_);
48
+ - int rv = getaddrinfo(host_.c_str(), port_str, &hints, &res);
49
+ + const char *resolve_host = connect_addr_.empty() ? host_.c_str() : connect_addr_.c_str();
50
+ + int rv = getaddrinfo(resolve_host, port_str, &hints, &res);
51
+ if (rv != 0) {
52
+ setError(gai_strerror(rv));
53
+ return -1;
54
+ @@ -269,6 +280,13 @@
55
+ if (::connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) {
56
+ memcpy(&remote_addr_, rp->ai_addr, rp->ai_addrlen);
57
+ remote_addrlen_ = (socklen_t)rp->ai_addrlen;
58
+ + char buf[INET6_ADDRSTRLEN];
59
+ + const void *src = (rp->ai_family == AF_INET)
60
+ + ? (void *)&((struct sockaddr_in *)rp->ai_addr)->sin_addr
61
+ + : (void *)&((struct sockaddr_in6 *)rp->ai_addr)->sin6_addr;
62
+ + if (inet_ntop(rp->ai_family, src, buf, sizeof(buf))) {
63
+ + resolved_address_ = buf;
64
+ + }
65
+ break;
66
+ }
67
+ ::close(fd);
68
+ @@ -651,30 +669,44 @@
69
+ }
70
+
71
+ void cleanup() {
72
+ - if (conn_) {
73
+ - ngtcp2_conn_del(conn_);
74
+ + ngtcp2_conn *conn_to_del = nullptr;
75
+ + void *ssl_to_free = nullptr;
76
+ + void *ssl_ctx_to_free = nullptr;
77
+ + int fd_to_close = -1;
78
+ + int wake0 = -1, wake1 = -1;
79
+ + {
80
+ + std::lock_guard<std::mutex> lock(cleanup_mutex_);
81
+ + conn_to_del = conn_;
82
+ conn_ = nullptr;
83
+ - }
84
+ - if (ssl_) {
85
+ - wolfSSL_free(ssl_);
86
+ + ssl_to_free = ssl_;
87
+ ssl_ = nullptr;
88
+ - }
89
+ - if (ssl_ctx_) {
90
+ - wolfSSL_CTX_free(ssl_ctx_);
91
+ + ssl_ctx_to_free = ssl_ctx_;
92
+ ssl_ctx_ = nullptr;
93
+ - }
94
+ - if (fd_ != -1) {
95
+ - ::close(fd_);
96
+ + fd_to_close = fd_;
97
+ fd_ = -1;
98
+ - }
99
+ - if (wakeup_fds_[0] != -1) {
100
+ - ::close(wakeup_fds_[0]);
101
+ + wake0 = wakeup_fds_[0];
102
+ + wake1 = wakeup_fds_[1];
103
+ wakeup_fds_[0] = -1;
104
+ - }
105
+ - if (wakeup_fds_[1] != -1) {
106
+ - ::close(wakeup_fds_[1]);
107
+ wakeup_fds_[1] = -1;
108
+ }
109
+ + if (conn_to_del) {
110
+ + ngtcp2_conn_del(conn_to_del);
111
+ + }
112
+ + if (ssl_to_free) {
113
+ + wolfSSL_free(static_cast<WOLFSSL *>(ssl_to_free));
114
+ + }
115
+ + if (ssl_ctx_to_free) {
116
+ + wolfSSL_CTX_free(static_cast<WOLFSSL_CTX *>(ssl_ctx_to_free));
117
+ + }
118
+ + if (fd_to_close != -1) {
119
+ + ::close(fd_to_close);
120
+ + }
121
+ + if (wake0 != -1) {
122
+ + ::close(wake0);
123
+ + }
124
+ + if (wake1 != -1) {
125
+ + ::close(wake1);
126
+ + }
127
+ }
128
+
129
+ void clearError() {
130
+ @@ -766,9 +798,14 @@
131
+ return client->on_handshake_completed();
132
+ }
133
+
134
+ + public:
135
+ + const std::string &resolved_address() const { return resolved_address_; }
136
+ +
137
+ private:
138
+ std::string host_;
139
+ + std::string connect_addr_;
140
+ uint16_t port_;
141
+ + std::string resolved_address_;
142
+
143
+ int fd_;
144
+ struct sockaddr_storage remote_addr_;
145
+ @@ -800,6 +837,8 @@
146
+
147
+ mutable std::mutex err_mutex_;
148
+ std::string last_error_str_;
149
+ +
150
+ + std::mutex cleanup_mutex_;
151
+ };
152
+
153
+ static std::map<jlong, std::unique_ptr<QuicClient>> connections;
154
+ @@ -827,6 +866,27 @@
155
+ return handle;
156
+ }
157
+
158
+ +JNIEXPORT jlong JNICALL
159
+ +Java_ai_annadata_mqttquic_quic_NGTCP2Client_nativeCreateConnectionWithAddress(
160
+ + JNIEnv *env, jobject thiz, jstring hostnameForTls, jstring connectAddress, jint port) {
161
+ + const char *tls_str = env->GetStringUTFChars(hostnameForTls, nullptr);
162
+ + const char *addr_str = env->GetStringUTFChars(connectAddress, nullptr);
163
+ + if (!tls_str || !addr_str) {
164
+ + if (tls_str) env->ReleaseStringUTFChars(hostnameForTls, tls_str);
165
+ + return 0;
166
+ + }
167
+ + std::string host_for_tls(tls_str);
168
+ + std::string connect_addr(addr_str);
169
+ + env->ReleaseStringUTFChars(hostnameForTls, tls_str);
170
+ + env->ReleaseStringUTFChars(connectAddress, addr_str);
171
+ +
172
+ + auto client = std::make_unique<QuicClient>(host_for_tls, connect_addr, (uint16_t)port);
173
+ + std::lock_guard<std::mutex> lock(connections_mutex);
174
+ + jlong handle = next_handle++;
175
+ + connections[handle] = std::move(client);
176
+ + return handle;
177
+ +}
178
+ +
179
+ JNIEXPORT jint JNICALL
180
+ Java_ai_annadata_mqttquic_quic_NGTCP2Client_nativeConnect(
181
+ JNIEnv *env, jobject thiz, jlong connHandle) {
182
+ @@ -929,6 +989,21 @@
183
+ return env->NewStringUTF("invalid connection");
184
+ }
185
+ return env->NewStringUTF(it->second->last_error());
186
+ +}
187
+ +
188
+ +JNIEXPORT jstring JNICALL
189
+ +Java_ai_annadata_mqttquic_quic_NGTCP2Client_nativeGetLastResolvedAddress(
190
+ + JNIEnv *env, jobject thiz, jlong connHandle) {
191
+ + std::lock_guard<std::mutex> lock(connections_mutex);
192
+ + auto it = connections.find(connHandle);
193
+ + if (it == connections.end()) {
194
+ + return nullptr;
195
+ + }
196
+ + const std::string &addr = it->second->resolved_address();
197
+ + if (addr.empty()) {
198
+ + return nullptr;
199
+ + }
200
+ + return env->NewStringUTF(addr.c_str());
201
+ }
202
+
203
+ // Debug-build alias: Kotlin/AGP can mangle the method name to include the module suffix.
204
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt
205
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt 2026-02-18 22:28:55
206
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt 2026-02-19 10:26:46
207
+ @@ -13,10 +13,13 @@
208
+ import android.util.Base64
209
+ import kotlinx.coroutines.CoroutineScope
210
+ import kotlinx.coroutines.Dispatchers
211
+ +import kotlinx.coroutines.delay
212
+ import kotlinx.coroutines.launch
213
+ +import kotlinx.coroutines.withContext
214
+ import java.nio.charset.StandardCharsets
215
+ import java.io.File
216
+ import java.io.IOException
217
+ +import java.net.InetAddress
218
+
219
+ /**
220
+ * Capacitor plugin bridge. Phase 3: connect/publish/subscribe call into MQTTClient.
221
+ @@ -27,6 +30,29 @@
222
+ private var client = MQTTClient(MQTTClient.ProtocolVersion.AUTO)
223
+ private val scope = CoroutineScope(Dispatchers.Main)
224
+
225
+ + /** Last resolved IP per host (used when DNS fails on reconnect). */
226
+ + @Volatile
227
+ + private var lastResolvedHost: String? = null
228
+ + @Volatile
229
+ + private var lastResolvedIp: String? = null
230
+ +
231
+ + /** Resolve hostname to IP for socket connect (avoids "No address" on reconnect). Returns null if resolution fails. */
232
+ + private fun resolveHostToIp(host: String): String? {
233
+ + return try {
234
+ + InetAddress.getAllByName(host).firstOrNull()?.hostAddress?.also { ip ->
235
+ + lastResolvedHost = host
236
+ + lastResolvedIp = ip
237
+ + }
238
+ + } catch (e: Exception) {
239
+ + null
240
+ + }
241
+ + }
242
+ +
243
+ + /** Use cached IP for this host if fresh resolve failed (e.g. on reconnect). */
244
+ + private fun resolveOrCachedIp(host: String): String? {
245
+ + return resolveHostToIp(host) ?: if (host == lastResolvedHost) lastResolvedIp else null
246
+ + }
247
+ +
248
+ private fun bundledCaFilePath(): String? {
249
+ return try {
250
+ val assetName = "mqttquic_ca.pem"
251
+ @@ -100,9 +126,38 @@
252
+ notifyListeners("message", data)
253
+ }
254
+ }
255
+ - client.connect(host, port, clientId, username, password, cleanSession ?: true, keepalive ?: 20, sessionExpiryInterval)
256
+ - call.resolve(JSObject().put("connected", true))
257
+ - notifyListeners("connected", JSObject().put("connected", true))
258
+ + // Resolve host to IP on IO so native getaddrinfo gets an IP (avoids "No address associated with hostname" on reconnect)
259
+ + val noAddressMsg = "No address associated with hostname"
260
+ + var lastException: Exception? = null
261
+ + for (attempt in 1..2) {
262
+ + try {
263
+ + withContext(Dispatchers.IO) {
264
+ + val resolvedIp = resolveOrCachedIp(host)
265
+ + client.connect(host, port, clientId, username, password, cleanSession ?: true, keepalive ?: 20, sessionExpiryInterval, connectAddress = resolvedIp)
266
+ + }
267
+ + // Cache resolved IP from native so reconnect can use it when Java DNS fails
268
+ + client.getLastResolvedAddress()?.let { ip ->
269
+ + lastResolvedHost = host
270
+ + lastResolvedIp = ip
271
+ + }
272
+ + call.resolve(JSObject().put("connected", true))
273
+ + notifyListeners("connected", JSObject().put("connected", true))
274
+ + return@launch
275
+ + } catch (e: Exception) {
276
+ + // Cache resolved IP from native even on failure (e.g. CONNACK timeout) so reconnect can use it
277
+ + client.getLastResolvedAddress()?.let { ip ->
278
+ + lastResolvedHost = host
279
+ + lastResolvedIp = ip
280
+ + }
281
+ + lastException = e
282
+ + if (attempt == 1 && e.message?.contains(noAddressMsg, ignoreCase = true) == true) {
283
+ + delay(2000L)
284
+ + continue
285
+ + }
286
+ + break
287
+ + }
288
+ + }
289
+ + call.reject(lastException?.message ?: "Connection failed")
290
+ } catch (e: Exception) {
291
+ call.reject(e.message ?: "Connection failed")
292
+ }
293
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt
294
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt 2026-02-19 13:50:48
295
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt 2026-02-19 12:34:46
296
+ @@ -1,5 +1,6 @@
297
+ package ai.annadata.mqttquic.client
298
+
299
+ +import android.util.Log
300
+ import ai.annadata.mqttquic.mqtt.MQTT5PropertyType
301
+ import ai.annadata.mqttquic.mqtt.MQTTConnAckCode
302
+ import ai.annadata.mqttquic.mqtt.MQTTMessageType
303
+ @@ -15,6 +16,7 @@
304
+ import ai.annadata.mqttquic.transport.MQTTStreamWriter
305
+ import ai.annadata.mqttquic.transport.QUICStreamReader
306
+ import ai.annadata.mqttquic.transport.QUICStreamWriter
307
+ +import kotlinx.coroutines.CompletableDeferred
308
+ import kotlinx.coroutines.CoroutineScope
309
+ import kotlinx.coroutines.Dispatchers
310
+ import kotlinx.coroutines.delay
311
+ @@ -24,6 +26,9 @@
312
+ import kotlinx.coroutines.sync.Mutex
313
+ import kotlinx.coroutines.sync.withLock
314
+ import kotlinx.coroutines.SupervisorJob
315
+ +import kotlinx.coroutines.CancellationException
316
+ +import kotlinx.coroutines.TimeoutCancellationException
317
+ +import kotlinx.coroutines.withTimeout
318
+
319
+ /**
320
+ * High-level MQTT client: connect, publish, subscribe, disconnect.
321
+ @@ -62,6 +67,10 @@
322
+ var onPublish: ((String, ByteArray) -> Unit)? = null
323
+ /** Per-connection Topic Alias map (alias -> topic name) for MQTT 5.0 incoming PUBLISH. */
324
+ private val topicAliasMap = mutableMapOf<Int, String>()
325
+ + /** Pending SUBACK by packet ID. Message loop completes with (fullPacket, hdrLen). Single reader: only message loop reads stream. */
326
+ + private val pendingSubacks = mutableMapOf<Int, CompletableDeferred<Pair<ByteArray, Int>>>()
327
+ + /** Pending UNSUBACK by packet ID. Message loop completes when UNSUBACK is read. */
328
+ + private val pendingUnsubacks = mutableMapOf<Int, CompletableDeferred<Unit>>()
329
+ private val lock = Mutex()
330
+ private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
331
+
332
+ @@ -77,6 +86,29 @@
333
+ /** Assigned Client Identifier from CONNACK when client sent empty ClientID; null otherwise. */
334
+ fun getAssignedClientIdentifier(): String? = runBlocking { lock.withLock { assignedClientIdentifier } }
335
+
336
+ + /** Resolved IP used for the current/last QUIC connection (from native getaddrinfo). Used by plugin to cache for reconnect when Java DNS fails. */
337
+ + fun getLastResolvedAddress(): String? = (quicClient as? NGTCP2Client)?.getLastResolvedAddress()
338
+ +
339
+ + /** Read full MQTT fixed header (1 byte type + 1–4 bytes remaining length per MQTT v5.0 §2.1.4). Returns (msgType, remLen, fixedHeaderBytes). */
340
+ + private suspend fun readFixedHeader(r: MQTTStreamReader): Triple<Byte, Int, ByteArray> {
341
+ + Log.i("MQTTClient", "readFixedHeader: requesting first byte")
342
+ + var fixed = r.readexactly(1).toMutableList()
343
+ + val firstByte = fixed[0].toInt() and 0xFF
344
+ + Log.i("MQTTClient", "readFixedHeader: got type byte 0x${Integer.toHexString(firstByte)} ($firstByte)")
345
+ + repeat(5) { // 1 type + up to 4 remaining-length bytes (Variable Byte Integer)
346
+ + try {
347
+ + val (rem, _) = MQTTProtocol.decodeRemainingLength(fixed.toByteArray(), 1)
348
+ + Log.i("MQTTClient", "readFixedHeader: decoded remLen=$rem fixedSize=${fixed.size}")
349
+ + return Triple(fixed[0], rem, fixed.toByteArray())
350
+ + } catch (_: IllegalArgumentException) {
351
+ + if (fixed.size > 5) throw IllegalArgumentException("Invalid remaining length")
352
+ + fixed.addAll(r.readexactly(1).toList())
353
+ + Log.i("MQTTClient", "readFixedHeader: added byte, fixedSize=${fixed.size}")
354
+ + }
355
+ + }
356
+ + throw IllegalArgumentException("Invalid remaining length")
357
+ + }
358
+ +
359
+ suspend fun connect(
360
+ host: String,
361
+ port: Int,
362
+ @@ -85,7 +117,8 @@
363
+ password: String?,
364
+ cleanSession: Boolean,
365
+ keepalive: Int,
366
+ - sessionExpiryInterval: Int? = null
367
+ + sessionExpiryInterval: Int? = null,
368
+ + connectAddress: String? = null
369
+ ) {
370
+ lock.withLock {
371
+ if (state == State.CONNECTING) {
372
+ @@ -109,7 +142,7 @@
373
+ } else {
374
+ QuicClientStub(connack.toList())
375
+ }
376
+ - quic.connect(host, port)
377
+ + quic.connect(host, port, connectAddress)
378
+ val s = quic.openStream()
379
+ val r = QUICStreamReader(s)
380
+ val w = QUICStreamWriter(s)
381
+ @@ -146,50 +179,118 @@
382
+ w.write(connectData)
383
+ w.drain()
384
+
385
+ - // MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK
386
+ + // MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK. Timeout so we don't block 30s until ERR_IDLE_CLOSE.
387
+ + // Efficient path: drain stream (read until empty), then parse first complete packet; repeat until CONNACK or timeout.
388
+ var full: ByteArray
389
+ var hdrLen: Int
390
+ - if (activeProtocolVersion == MQTTProtocolLevel.V5) {
391
+ - while (true) {
392
+ - val fixed = r.readexactly(2)
393
+ - val (msgType, remLen, hLen) = MQTTProtocol.parseFixedHeader(fixed)
394
+ - val rest = r.readexactly(remLen)
395
+ - full = fixed + rest
396
+ - hdrLen = hLen
397
+ - when (msgType) {
398
+ - MQTTMessageType.CONNACK -> break
399
+ - MQTTMessageType.AUTH -> {
400
+ - lock.withLock { state = State.ERROR }
401
+ - try {
402
+ - w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
403
+ - w.drain()
404
+ - w.close()
405
+ - } catch (_: Exception) { /* ignore */ }
406
+ - throw IllegalArgumentException("Enhanced authentication not supported")
407
+ + val connackTimeoutMs = 15_000L
408
+ + try {
409
+ + if (activeProtocolVersion == MQTTProtocolLevel.V5) {
410
+ + Log.i("MQTTClient", "reading CONNACK (MQTT5) timeout=${connackTimeoutMs}ms (drain then parse)")
411
+ + withTimeout(connackTimeoutMs) {
412
+ + while (true) {
413
+ + if (r is QUICStreamReader) {
414
+ + r.drain()
415
+ + val avail = r.available()
416
+ + Log.i("MQTTClient", "CONNACK loop: after drain available=$avail")
417
+ + val packet = r.tryConsumeNextPacket()
418
+ + if (packet != null) {
419
+ + val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
420
+ + val typeByte = msgType.toInt() and 0xFF
421
+ + Log.i("MQTTClient", "CONNACK loop: packet type=0x${Integer.toHexString(typeByte)} len=${packet.size} hdrLen=$fixedLen")
422
+ + when (typeByte) {
423
+ + 0x20 -> {
424
+ + full = packet
425
+ + hdrLen = fixedLen
426
+ + return@withTimeout
427
+ + }
428
+ + 0xF0 -> {
429
+ + lock.withLock { state = State.ERROR }
430
+ + try {
431
+ + w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
432
+ + w.drain()
433
+ + w.close()
434
+ + } catch (_: Exception) { /* ignore */ }
435
+ + throw IllegalArgumentException("Enhanced authentication not supported")
436
+ + }
437
+ + 0xE0 -> {
438
+ + lock.withLock { state = State.ERROR }
439
+ + throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
440
+ + }
441
+ + else -> Log.i("MQTTClient", "CONNACK loop: skipping non-CONNACK packet type=0x${Integer.toHexString(typeByte)}")
442
+ + }
443
+ + } else {
444
+ + // Only delay when no data; if we have data but no packet, retry soon (avoid suspend then timeout before next iteration)
445
+ + if (avail == 0) delay(50) else delay(10)
446
+ + }
447
+ + } else {
448
+ + // Fallback for non-QUIC reader (e.g. mock)
449
+ + val (msgType, remLen, fixed) = readFixedHeader(r)
450
+ + val typeByte = fixed[0].toInt() and 0xFF
451
+ + val rest = r.readexactly(remLen)
452
+ + full = fixed + rest
453
+ + hdrLen = fixed.size
454
+ + when (typeByte) {
455
+ + 0x20 -> return@withTimeout
456
+ + 0xF0 -> {
457
+ + lock.withLock { state = State.ERROR }
458
+ + try {
459
+ + w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
460
+ + w.drain()
461
+ + w.close()
462
+ + } catch (_: Exception) { /* ignore */ }
463
+ + throw IllegalArgumentException("Enhanced authentication not supported")
464
+ + }
465
+ + 0xE0 -> {
466
+ + lock.withLock { state = State.ERROR }
467
+ + throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
468
+ + }
469
+ + else -> { /* skip; continue */ }
470
+ + }
471
+ + }
472
+ }
473
+ - MQTTMessageType.DISCONNECT -> {
474
+ - lock.withLock { state = State.ERROR }
475
+ - throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
476
+ + }
477
+ + Log.i("MQTTClient", "got CONNACK (MQTT5) (fullLen=${full.size} hdrLen=$hdrLen)")
478
+ + } else {
479
+ + Log.i("MQTTClient", "reading CONNACK (3.1.1) timeout=${connackTimeoutMs}ms")
480
+ + withTimeout(connackTimeoutMs) {
481
+ + if (r is QUICStreamReader) {
482
+ + while (true) {
483
+ + r.drain()
484
+ + val packet = r.tryConsumeNextPacket()
485
+ + if (packet != null) {
486
+ + val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
487
+ + if (msgType == MQTTMessageType.CONNACK) {
488
+ + full = packet
489
+ + hdrLen = fixedLen
490
+ + break
491
+ + }
492
+ + lock.withLock { state = State.ERROR }
493
+ + throw IllegalArgumentException("expected CONNACK, got $msgType")
494
+ + }
495
+ + delay(50)
496
+ + }
497
+ + } else {
498
+ + val (msgType, remLen, fixed) = readFixedHeader(r)
499
+ + val rest = r.readexactly(remLen)
500
+ + full = fixed + rest
501
+ + hdrLen = fixed.size
502
+ + if (msgType != MQTTMessageType.CONNACK) {
503
+ + lock.withLock { state = State.ERROR }
504
+ + throw IllegalArgumentException("expected CONNACK, got $msgType")
505
+ + }
506
+ }
507
+ - else -> {
508
+ - lock.withLock { state = State.ERROR }
509
+ - throw IllegalArgumentException("Expected CONNACK or AUTH, got $msgType")
510
+ - }
511
+ + Log.i("MQTTClient", "got CONNACK (3.1.1)")
512
+ }
513
+ }
514
+ - } else {
515
+ - val fixed = r.readexactly(2)
516
+ - val (msgType, remLen, hLen) = MQTTProtocol.parseFixedHeader(fixed)
517
+ - val rest = r.readexactly(remLen)
518
+ - full = fixed + rest
519
+ - hdrLen = hLen
520
+ - if (msgType != MQTTMessageType.CONNACK) {
521
+ - lock.withLock { state = State.ERROR }
522
+ - throw IllegalArgumentException("expected CONNACK, got $msgType")
523
+ - }
524
+ + } catch (e: TimeoutCancellationException) {
525
+ + lock.withLock { state = State.ERROR }
526
+ + Log.w("MQTTClient", "CONNACK read timed out after ${connackTimeoutMs}ms", e)
527
+ + 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)
528
+ }
529
+
530
+ if (activeProtocolVersion == MQTTProtocolLevel.V5) {
531
+ + Log.i("MQTTClient", "parsing CONNACK v5 (offset=$hdrLen)")
532
+ val (_, reasonCode, props) = MQTT5Protocol.parseConnackV5(full, hdrLen)
533
+ if (reasonCode != MQTT5ReasonCode.SUCCESS) {
534
+ lock.withLock { state = State.ERROR }
535
+ @@ -210,21 +311,37 @@
536
+ }
537
+
538
+ lock.withLock { state = State.CONNECTED }
539
+ + Log.i("MQTTClient", "state=CONNECTED, starting message and keepalive loops")
540
+ startMessageLoop()
541
+ startKeepaliveLoop()
542
+ } catch (e: Exception) {
543
+ - val wr = lock.withLock {
544
+ + val (wr, quic) = lock.withLock {
545
+ val w = writer
546
+ + val q = quicClient
547
+ quicClient = null
548
+ stream = null
549
+ reader = null
550
+ writer = null
551
+ state = State.ERROR
552
+ - w
553
+ + Pair(w, q)
554
+ }
555
+ try {
556
+ wr?.close()
557
+ } catch (_: Exception) { /* ignore */ }
558
+ + // Skip quic.close() on timeout/cancellation: server may have already sent idle close, and native close() can crash. Prefer leak over crash.
559
+ + val isTimeoutOrCancel = e is TimeoutCancellationException ||
560
+ + e is CancellationException ||
561
+ + e.cause is TimeoutCancellationException ||
562
+ + (e.message?.contains("Timed out", ignoreCase = true) == true)
563
+ + if (!isTimeoutOrCancel) {
564
+ + try {
565
+ + quic?.close()
566
+ + } catch (ex: Exception) {
567
+ + Log.w("MQTTClient", "Error closing QUIC connection on connect failure", ex)
568
+ + }
569
+ + } else {
570
+ + Log.i("MQTTClient", "Skipping quic.close() on timeout/cancellation to avoid native crash")
571
+ + }
572
+ throw e
573
+ }
574
+ }
575
+ @@ -260,59 +377,66 @@
576
+
577
+ suspend fun subscribe(topic: String, qos: Int, subscriptionIdentifier: Int? = null) {
578
+ if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
579
+ - val (r, w, version) = lock.withLock { Triple(reader, writer, activeProtocolVersion) }
580
+ - if (r == null || w == null) throw IllegalStateException("no reader/writer")
581
+ + val (w, version) = lock.withLock { writer to activeProtocolVersion }
582
+ + if (w == null) throw IllegalStateException("no writer")
583
+
584
+ val pid = nextPacketIdUsed()
585
+ - val data: ByteArray
586
+ - if (version == MQTTProtocolLevel.V5) {
587
+ - data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
588
+ - } else {
589
+ - data = MQTTProtocol.buildSubscribe(pid, topic, qos)
590
+ - }
591
+ - w.write(data)
592
+ - w.drain()
593
+ + val deferred = CompletableDeferred<Pair<ByteArray, Int>>()
594
+ + lock.withLock { pendingSubacks[pid] = deferred }
595
+ + try {
596
+ + val data: ByteArray
597
+ + if (version == MQTTProtocolLevel.V5) {
598
+ + data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
599
+ + } else {
600
+ + data = MQTTProtocol.buildSubscribe(pid, topic, qos)
601
+ + }
602
+ + w.write(data)
603
+ + w.drain()
604
+
605
+ - val fixed = r.readexactly(2)
606
+ - val (_, remLen, hdrLen) = MQTTProtocol.parseFixedHeader(fixed)
607
+ - val rest = r.readexactly(remLen)
608
+ - val full = fixed + rest
609
+ -
610
+ - if (version == MQTTProtocolLevel.V5) {
611
+ - val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
612
+ - if (reasonCodes.isNotEmpty()) {
613
+ - val firstRC = reasonCodes[0]
614
+ - if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
615
+ - throw IllegalArgumentException("SUBACK error $firstRC")
616
+ + val (full, hdrLen) = withTimeout(15_000L) { deferred.await() }
617
+ +
618
+ + if (version == MQTTProtocolLevel.V5) {
619
+ + val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
620
+ + if (reasonCodes.isNotEmpty()) {
621
+ + val firstRC = reasonCodes[0]
622
+ + if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
623
+ + throw IllegalArgumentException("SUBACK error $firstRC")
624
+ + }
625
+ }
626
+ + } else {
627
+ + val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
628
+ + if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
629
+ }
630
+ - } else {
631
+ - val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
632
+ - if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
633
+ + } finally {
634
+ + lock.withLock { pendingSubacks.remove(pid) }
635
+ }
636
+ }
637
+
638
+ suspend fun unsubscribe(topic: String) {
639
+ if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
640
+ - val (r, w, version) = lock.withLock {
641
+ + val (w, version) = lock.withLock {
642
+ subscribedTopics.remove(topic)
643
+ - Triple(reader, writer, activeProtocolVersion)
644
+ + writer to activeProtocolVersion
645
+ }
646
+ - if (r == null || w == null) throw IllegalStateException("no reader/writer")
647
+ + if (w == null) throw IllegalStateException("no writer")
648
+
649
+ val pid = nextPacketIdUsed()
650
+ - val data: ByteArray
651
+ - if (version == MQTTProtocolLevel.V5) {
652
+ - data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
653
+ - } else {
654
+ - data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
655
+ - }
656
+ - w.write(data)
657
+ - w.drain()
658
+ + val deferred = CompletableDeferred<Unit>()
659
+ + lock.withLock { pendingUnsubacks[pid] = deferred }
660
+ + try {
661
+ + val data: ByteArray
662
+ + if (version == MQTTProtocolLevel.V5) {
663
+ + data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
664
+ + } else {
665
+ + data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
666
+ + }
667
+ + w.write(data)
668
+ + w.drain()
669
+
670
+ - val fixed = r.readexactly(2)
671
+ - val (_, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
672
+ - r.readexactly(remLen)
673
+ + withTimeout(15_000L) { deferred.await() }
674
+ + } finally {
675
+ + lock.withLock { pendingUnsubacks.remove(pid) }
676
+ + }
677
+ }
678
+
679
+ suspend fun disconnect() {
680
+ @@ -323,6 +447,8 @@
681
+ job?.cancel()
682
+ job?.join()
683
+
684
+ + failPendingSubacksUnsubacks(IllegalStateException("Disconnected"))
685
+ +
686
+ val (w, version) = lock.withLock {
687
+ val wr = writer
688
+ val v = activeProtocolVersion
689
+ @@ -369,6 +495,15 @@
690
+ pid
691
+ }
692
+
693
+ + private suspend fun failPendingSubacksUnsubacks(cause: Throwable) {
694
+ + lock.withLock {
695
+ + pendingSubacks.values.forEach { it.completeExceptionally(cause) }
696
+ + pendingSubacks.clear()
697
+ + pendingUnsubacks.values.forEach { it.completeExceptionally(cause) }
698
+ + pendingUnsubacks.clear()
699
+ + }
700
+ + }
701
+ +
702
+ /** Send PINGREQ at effectiveKeepalive interval so server sees activity and does not close (idle/keepalive). [MQTT-3.1.2-20] */
703
+ private fun startKeepaliveLoop() {
704
+ keepaliveJob?.cancel()
705
+ @@ -394,11 +529,24 @@
706
+ while (isActive) {
707
+ val r = lock.withLock { reader } ?: break
708
+ try {
709
+ - val fixed = r.readexactly(2)
710
+ - val (msgType, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
711
+ + val (msgType, remLen, fixed) = readFixedHeader(r)
712
+ val rest = r.readexactly(remLen)
713
+ val type = (msgType.toInt() and 0xF0).toByte()
714
+ when (type) {
715
+ + MQTTMessageType.SUBACK -> {
716
+ + if (rest.size >= 2) {
717
+ + val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
718
+ + val full = fixed + rest
719
+ + val hdrLen = fixed.size
720
+ + lock.withLock { pendingSubacks.remove(pid)?.complete(Pair(full, hdrLen)) }
721
+ + }
722
+ + }
723
+ + MQTTMessageType.UNSUBACK -> {
724
+ + if (rest.size >= 2) {
725
+ + val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
726
+ + lock.withLock { pendingUnsubacks.remove(pid)?.complete(Unit) }
727
+ + }
728
+ + }
729
+ MQTTMessageType.DISCONNECT -> {
730
+ val reasonCode = if (rest.isNotEmpty()) rest[0].toInt() and 0xFF else 0x00
731
+ lock.withLock {
732
+ @@ -412,6 +560,7 @@
733
+ topicAliasMap.clear()
734
+ state = if (reasonCode >= 0x80) State.ERROR else State.DISCONNECTED
735
+ }
736
+ + failPendingSubacksUnsubacks(IllegalStateException("Server sent DISCONNECT"))
737
+ break
738
+ }
739
+ MQTTMessageType.PINGREQ -> {
740
+ @@ -473,6 +622,7 @@
741
+ topicAliasMap.clear()
742
+ state = State.DISCONNECTED
743
+ }
744
+ + failPendingSubacksUnsubacks(e)
745
+ }
746
+ break
747
+ }
748
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt
749
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt 2026-02-19 13:50:43
750
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt 2026-02-19 13:05:52
751
+ @@ -40,10 +40,10 @@
752
+ val b = data[i].toInt() and 0xFF
753
+ len += (b and 0x7F) * mul
754
+ i++
755
+ - if ((b and 0x80) == 0) return@repeat
756
+ + if ((b and 0x80) == 0) return len to (i - offset)
757
+ mul *= 128
758
+ }
759
+ - return len to (i - offset)
760
+ + throw IllegalArgumentException("Invalid remaining length (max 4 bytes)")
761
+ }
762
+
763
+ fun encodeString(s: String): ByteArray {
764
+ @@ -73,6 +73,37 @@
765
+ val msgType = data[0]
766
+ val (rem, consumed) = decodeRemainingLength(data, 1)
767
+ return Triple(msgType, rem, 1 + consumed)
768
+ + }
769
+ +
770
+ + /**
771
+ + * Returns total MQTT packet length (fixed header + payload) if buffer has at least a decodable fixed header, else null.
772
+ + * Use when draining stream: accumulate bytes, then call this; when buffer.size >= length, you have a complete packet.
773
+ + */
774
+ + fun getNextPacketLength(buffer: ByteArray): Int? {
775
+ + if (buffer.size < 2) return null
776
+ + // CONNACK (0x20): often single-byte remaining length (e.g. 0x20 = 32 → total 34). Handle that first.
777
+ + if (buffer[0].toInt() and 0xFF == 0x20) {
778
+ + val b1 = buffer[1].toInt() and 0xFF
779
+ + if ((b1 and 0x80) == 0) {
780
+ + val rem = b1
781
+ + val total = 1 + 1 + rem
782
+ + if (total <= buffer.size) return total
783
+ + } else {
784
+ + try {
785
+ + val (rem, consumed) = decodeRemainingLength(buffer, 1)
786
+ + val total = 1 + consumed + rem
787
+ + if (total <= buffer.size) return total
788
+ + } catch (_: IllegalArgumentException) { /* fall through */ }
789
+ + }
790
+ + }
791
+ + for (len in minOf(5, buffer.size) downTo 2) {
792
+ + try {
793
+ + val (_, rem, fixedLen) = parseFixedHeader(buffer.copyOf(len))
794
+ + val total = fixedLen + rem
795
+ + if (total in 1..buffer.size) return total
796
+ + } catch (_: IllegalArgumentException) { /* need more bytes for remaining length */ }
797
+ + }
798
+ + return null
799
+ }
800
+
801
+ fun buildConnect(
802
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt
803
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt 2026-02-19 13:50:50
804
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt 2026-02-19 12:10:12
805
+ @@ -40,22 +40,28 @@
806
+
807
+ // Native methods (implemented in ngtcp2_jni.cpp)
808
+ private external fun nativeCreateConnection(host: String, port: Int): Long
809
+ + private external fun nativeCreateConnectionWithAddress(hostnameForTls: String, connectAddress: String, port: Int): Long
810
+ private external fun nativeConnect(connHandle: Long): Int
811
+ private external fun nativeOpenStream(connHandle: Long): Long
812
+ private external fun nativeWriteStream(connHandle: Long, streamId: Long, data: ByteArray): Int
813
+ private external fun nativeReadStream(connHandle: Long, streamId: Long): ByteArray?
814
+ private external fun nativeClose(connHandle: Long)
815
+ private external fun nativeIsConnected(connHandle: Long): Boolean
816
+ - internal external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
817
+ + // Public to avoid Kotlin internal-name mangling (nativeCloseStream$module) which breaks JNI lookup
818
+ + external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
819
+ @JvmName("nativeGetLastError")
820
+ - internal external fun nativeGetLastError(connHandle: Long): String
821
+ -
822
+ + external fun nativeGetLastError(connHandle: Long): String
823
+ + private external fun nativeGetLastResolvedAddress(connHandle: Long): String?
824
+ +
825
+ + /** Resolved IP used for this connection (set after init_socket in native). Use for reconnect cache when Java DNS fails. */
826
+ + fun getLastResolvedAddress(): String? = if (connHandle != 0L) nativeGetLastResolvedAddress(connHandle) else null
827
+ +
828
+ // Connection state
829
+ private var connHandle: Long = 0
830
+ private var isConnected: Boolean = false
831
+ private val streams = mutableMapOf<Long, NGTCP2Stream>()
832
+
833
+ - override suspend fun connect(host: String, port: Int) {
834
+ + override suspend fun connect(host: String, port: Int, connectAddress: String?) {
835
+ if (!isAvailable()) {
836
+ throw IllegalStateException("ngtcp2 native library is not loaded")
837
+ }
838
+ @@ -63,8 +69,12 @@
839
+ throw IllegalStateException("Already connected")
840
+ }
841
+
842
+ - // Create native connection
843
+ - connHandle = nativeCreateConnection(host, port)
844
+ + // Create native connection (use pre-resolved IP when given to avoid "No address" on reconnect)
845
+ + connHandle = if (!connectAddress.isNullOrBlank()) {
846
+ + nativeCreateConnectionWithAddress(host, connectAddress, port)
847
+ + } else {
848
+ + nativeCreateConnection(host, port)
849
+ + }
850
+ if (connHandle == 0L) {
851
+ throw IllegalStateException("Failed to create QUIC connection")
852
+ }
853
+ @@ -147,19 +157,19 @@
854
+
855
+ private var isClosed: Boolean = false
856
+
857
+ + /**
858
+ + * Read up to maxBytes from the stream. Returns immediately with whatever is
859
+ + * currently available (possibly empty). This allows drain()-then-parse logic
860
+ + * to complete: first read returns CONNACK bytes, second read returns empty
861
+ + * so drain() breaks and tryConsumeNextPacket() can run. Never blocks waiting
862
+ + * for more data (native recv_buf is already populated by the event loop).
863
+ + */
864
+ override suspend fun read(maxBytes: Int): ByteArray {
865
+ if (isClosed) {
866
+ throw IllegalStateException("Stream is closed")
867
+ }
868
+ -
869
+ - while (!isClosed) {
870
+ - val data = client.readStreamData(streamId)
871
+ - if (data != null && data.isNotEmpty()) {
872
+ - return data
873
+ - }
874
+ - delay(5)
875
+ - }
876
+ - return ByteArray(0)
877
+ + val data = client.readStreamData(streamId) ?: return ByteArray(0)
878
+ + return data
879
+ }
880
+
881
+ override suspend fun write(data: ByteArray) {
882
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt
883
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt 2026-02-18 22:28:55
884
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt 2026-02-19 10:19:11
885
+ @@ -36,7 +36,7 @@
886
+ private var buffer: MockStreamBuffer? = null
887
+ private var streamId = 0L
888
+
889
+ - override suspend fun connect(host: String, port: Int) {
890
+ + override suspend fun connect(host: String, port: Int, connectAddress: String?) {
891
+ buffer = MockStreamBuffer(initialReadData.toByteArray())
892
+ streamId = 0L
893
+ }
894
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt
895
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt 2026-02-18 22:28:55
896
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt 2026-02-19 10:16:31
897
+ @@ -15,7 +15,7 @@
898
+ * QUIC client: connect, TLS handshake, open one bidirectional stream.
899
+ */
900
+ interface QuicClient {
901
+ - suspend fun connect(host: String, port: Int)
902
+ + suspend fun connect(host: String, port: Int, connectAddress: String? = null)
903
+ suspend fun openStream(): QuicStream
904
+ suspend fun close()
905
+ }
906
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt
907
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt 2026-02-19 13:50:41
908
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt 2026-02-19 12:21:15
909
+ @@ -1,21 +1,96 @@
910
+ package ai.annadata.mqttquic.transport
911
+
912
+ +import android.util.Log
913
+ +import ai.annadata.mqttquic.mqtt.MQTTProtocol
914
+ import ai.annadata.mqttquic.quic.QuicStream
915
+ +import kotlinx.coroutines.delay
916
+
917
+ /**
918
+ - * MQTTStreamReader over QUIC stream. Mirrors NGTCP2StreamReader.
919
+ + * MQTTStreamReader over QUIC stream. Buffers excess bytes so readexactly(n)
920
+ + * and read(maxBytes) get exactly the requested amount; native may return
921
+ + * a full CONNACK (e.g. 18 bytes) in one read, so we must not lose the remainder.
922
+ + *
923
+ + * Efficient CONNACK/packet read: call [drain] to read until the stream has no more
924
+ + * data, then [tryConsumeNextPacket] to take the first complete MQTT packet from
925
+ + * the buffer. Repeat drain + tryConsumeNextPacket (with short delay) until you
926
+ + * get a packet or timeout.
927
+ */
928
+ class QUICStreamReader(private val stream: QuicStream) : MQTTStreamReader {
929
+
930
+ - override suspend fun read(maxBytes: Int): ByteArray = stream.read(maxBytes)
931
+ + private val buffer = mutableListOf<Byte>()
932
+
933
+ + override suspend fun available(): Int = buffer.size
934
+ +
935
+ + /** Read from stream until no more data is available (drained). Call before tryConsumeNextPacket. */
936
+ + suspend fun drain() {
937
+ + while (true) {
938
+ + val chunk = stream.read(8192)
939
+ + if (chunk.isEmpty()) break
940
+ + Log.i("MQTTClient", "QUICStreamReader: drain got ${chunk.size} bytes bufferTotal=${buffer.size + chunk.size}")
941
+ + buffer.addAll(chunk.toList())
942
+ + }
943
+ + }
944
+ +
945
+ + /** Consume the first n bytes from buffer and return them. Caller must ensure buffer.size >= n. */
946
+ + fun consume(n: Int): ByteArray {
947
+ + if (buffer.size < n) throw IllegalArgumentException("buffer has ${buffer.size} < $n")
948
+ + val out = buffer.take(n).toByteArray()
949
+ + repeat(n) { buffer.removeAt(0) }
950
+ + return out
951
+ + }
952
+ +
953
+ + /**
954
+ + * If buffer contains at least one complete MQTT packet (fixed header + payload), consume and return it; else return null.
955
+ + * Call after [drain]; if null, delay and drain again (or timeout).
956
+ + */
957
+ + fun tryConsumeNextPacket(): ByteArray? {
958
+ + val buf = buffer.toByteArray()
959
+ + val totalLen = MQTTProtocol.getNextPacketLength(buf)
960
+ + if (totalLen == null) {
961
+ + if (buf.isNotEmpty()) {
962
+ + Log.w("MQTTClient", "QUICStreamReader: getNextPacketLength returned null bufferSize=${buf.size} firstByte=0x${Integer.toHexString(buf[0].toInt() and 0xFF)}")
963
+ + }
964
+ + return null
965
+ + }
966
+ + if (buffer.size < totalLen) {
967
+ + Log.i("MQTTClient", "QUICStreamReader: buffer.size=${buffer.size} < totalLen=$totalLen waiting for more")
968
+ + return null
969
+ + }
970
+ + val packet = consume(totalLen)
971
+ + Log.i("MQTTClient", "QUICStreamReader: tryConsumeNextPacket consumed $totalLen bytes type=0x${Integer.toHexString(packet[0].toInt() and 0xFF)}")
972
+ + return packet
973
+ + }
974
+ +
975
+ + override suspend fun read(maxBytes: Int): ByteArray {
976
+ + while (buffer.size < maxBytes) {
977
+ + val chunk = stream.read(maxBytes - buffer.size)
978
+ + if (chunk.isEmpty()) break
979
+ + Log.i("MQTTClient", "QUICStreamReader: got chunk=${chunk.size} bufferSize=${buffer.size + chunk.size}")
980
+ + buffer.addAll(chunk.toList())
981
+ + }
982
+ + val n = minOf(maxBytes, buffer.size)
983
+ + if (n == 0) return ByteArray(0)
984
+ + val result = buffer.subList(0, n).toByteArray()
985
+ + repeat(n) { buffer.removeAt(0) }
986
+ + Log.i("MQTTClient", "QUICStreamReader: returning $n bytes bufferRemain=${buffer.size}")
987
+ + return result
988
+ + }
989
+ +
990
+ override suspend fun readexactly(n: Int): ByteArray {
991
+ + Log.i("MQTTClient", "QUICStreamReader: readexactly($n) bufferHas=${buffer.size}")
992
+ val acc = mutableListOf<Byte>()
993
+ while (acc.size < n) {
994
+ - val chunk = stream.read(n - acc.size)
995
+ - if (chunk.isEmpty()) throw IllegalArgumentException("readexactly")
996
+ - acc.addAll(chunk.toList())
997
+ + drain()
998
+ + val fromBuffer = minOf(n - acc.size, buffer.size)
999
+ + if (fromBuffer > 0) {
1000
+ + acc.addAll(buffer.subList(0, fromBuffer).toList())
1001
+ + repeat(fromBuffer) { buffer.removeAt(0) }
1002
+ + } else {
1003
+ + // No data yet (e.g. message loop waiting for SUBACK/PUBLISH). Wait and retry instead of throwing.
1004
+ + delay(20L)
1005
+ + }
1006
+ }
1007
+ + Log.i("MQTTClient", "QUICStreamReader: readexactly($n) done")
1008
+ return acc.toByteArray()
1009
+ }
1010
+ }
1011
+ diff -Naur capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt
1012
+ --- capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt 2026-02-18 22:28:55
1013
+ +++ annadata-app/node_modules/@annadata/capacitor-mqtt-quic/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt 2026-02-19 08:09:34
1014
+ @@ -7,6 +7,8 @@
1015
+ * Phase 2 implements over QUIC stream.
1016
+ */
1017
+ interface MQTTStreamReader {
1018
+ + /** Number of bytes currently buffered (without reading from stream). Used to skip unwanted packets only when safe. */
1019
+ + suspend fun available(): Int
1020
+ suspend fun read(maxBytes: Int): ByteArray
1021
+ suspend fun readexactly(n: Int): ByteArray
1022
+ }
1023
+ @@ -49,6 +51,8 @@
1024
+ * Mock reader over MockStreamBuffer.
1025
+ */
1026
+ class MockStreamReader(private val buffer: MockStreamBuffer) : MQTTStreamReader {
1027
+ +
1028
+ + override suspend fun available(): Int = buffer.readBuffer.size
1029
+
1030
+ override suspend fun read(maxBytes: Int): ByteArray {
1031
+ if (buffer.isClosed && buffer.readBuffer.isEmpty()) return ByteArray(0)