@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
|
@@ -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)
|