@annadata/capacitor-mqtt-quic 0.1.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 +399 -0
- package/android/NGTCP2_BUILD_INSTRUCTIONS.md +319 -0
- package/android/build-nghttp3.sh +182 -0
- package/android/build-ngtcp2.sh +289 -0
- package/android/build-openssl.sh +302 -0
- package/android/build.gradle +75 -0
- package/android/gradle.properties +3 -0
- package/android/proguard-rules.pro +2 -0
- package/android/settings.gradle +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/assets/mqttquic_ca.pem +5 -0
- package/android/src/main/cpp/CMakeLists.txt +157 -0
- package/android/src/main/cpp/ngtcp2_jni.cpp +928 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +232 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +339 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Properties.kt +250 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Protocol.kt +281 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5ReasonCodes.kt +109 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +249 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTTypes.kt +47 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +184 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +54 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +21 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +37 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +91 -0
- package/android/src/test/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocolTest.kt +92 -0
- package/dist/esm/definitions.d.ts +66 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +28 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +183 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +217 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +215 -0
- package/dist/plugin.js.map +1 -0
- package/ios/MqttQuicPlugin.podspec +34 -0
- package/ios/NGTCP2_BUILD_INSTRUCTIONS.md +302 -0
- package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +343 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Properties.swift +280 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Protocol.swift +333 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5ReasonCodes.swift +113 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTTProtocol.swift +322 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTTTypes.swift +54 -0
- package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +229 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.h +29 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.mm +865 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Client.swift +262 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/QuicClientStub.swift +66 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/QuicTypes.swift +23 -0
- package/ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem +5 -0
- package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +50 -0
- package/ios/Sources/MqttQuicPlugin/Transport/StreamTransport.swift +105 -0
- package/ios/Tests/MQTTProtocolTests.swift +82 -0
- package/ios/build-nghttp3.sh +173 -0
- package/ios/build-ngtcp2.sh +278 -0
- package/ios/build-openssl.sh +405 -0
- package/package.json +63 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.mqtt
|
|
2
|
+
|
|
3
|
+
import java.nio.ByteBuffer
|
|
4
|
+
import java.nio.charset.StandardCharsets
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MQTT 3.1.1 encode/decode. Matches MQTTD mqttd/protocol.py.
|
|
8
|
+
*/
|
|
9
|
+
object MQTTProtocol {
|
|
10
|
+
|
|
11
|
+
const val PROTOCOL_NAME = "MQTT"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Encode remaining length (1–4 bytes). Max 268_435_455.
|
|
15
|
+
*/
|
|
16
|
+
fun encodeRemainingLength(length: Int): ByteArray {
|
|
17
|
+
if (length < 0 || length > 268_435_455) {
|
|
18
|
+
throw IllegalArgumentException("Invalid remaining length: $length")
|
|
19
|
+
}
|
|
20
|
+
val enc = mutableListOf<Byte>()
|
|
21
|
+
var n = length
|
|
22
|
+
do {
|
|
23
|
+
var b = (n % 128).toByte()
|
|
24
|
+
n /= 128
|
|
25
|
+
if (n > 0) b = (b.toInt() or 0x80).toByte()
|
|
26
|
+
enc.add(b)
|
|
27
|
+
} while (n > 0)
|
|
28
|
+
return enc.toByteArray()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decode remaining length. Returns Pair(length, bytesConsumed).
|
|
33
|
+
*/
|
|
34
|
+
fun decodeRemainingLength(data: ByteArray, offset: Int = 0): Pair<Int, Int> {
|
|
35
|
+
var mul = 1
|
|
36
|
+
var len = 0
|
|
37
|
+
var i = offset
|
|
38
|
+
repeat(4) {
|
|
39
|
+
if (i >= data.size) throw IllegalArgumentException("Insufficient data for remaining length")
|
|
40
|
+
val b = data[i].toInt() and 0xFF
|
|
41
|
+
len += (b and 0x7F) * mul
|
|
42
|
+
i++
|
|
43
|
+
if ((b and 0x80) == 0) return@repeat
|
|
44
|
+
mul *= 128
|
|
45
|
+
}
|
|
46
|
+
return len to (i - offset)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fun encodeString(s: String): ByteArray {
|
|
50
|
+
val utf8 = s.toByteArray(StandardCharsets.UTF_8)
|
|
51
|
+
if (utf8.size > 0xFFFF) throw IllegalArgumentException("String too long")
|
|
52
|
+
return byteArrayOf(
|
|
53
|
+
(utf8.size shr 8).toByte(),
|
|
54
|
+
(utf8.size and 0xFF).toByte()
|
|
55
|
+
) + utf8
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fun decodeString(data: ByteArray, offset: Int): Pair<String, Int> {
|
|
59
|
+
if (offset + 2 > data.size) throw IllegalArgumentException("Insufficient data for string length")
|
|
60
|
+
val strLen = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
|
61
|
+
val start = offset + 2
|
|
62
|
+
if (start + strLen > data.size) throw IllegalArgumentException("Insufficient data for string content")
|
|
63
|
+
val sub = data.copyOfRange(start, start + strLen)
|
|
64
|
+
val s = String(sub, StandardCharsets.UTF_8)
|
|
65
|
+
return s to (start + strLen)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns Triple(messageType, remainingLength, bytesConsumed).
|
|
70
|
+
*/
|
|
71
|
+
fun parseFixedHeader(data: ByteArray): Triple<Byte, Int, Int> {
|
|
72
|
+
if (data.size < 2) throw IllegalArgumentException("Insufficient data for fixed header")
|
|
73
|
+
val msgType = data[0]
|
|
74
|
+
val (rem, consumed) = decodeRemainingLength(data, 1)
|
|
75
|
+
return Triple(msgType, rem, 1 + consumed)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fun buildConnect(
|
|
79
|
+
clientId: String,
|
|
80
|
+
username: String? = null,
|
|
81
|
+
password: String? = null,
|
|
82
|
+
keepalive: Int = 60,
|
|
83
|
+
cleanSession: Boolean = true
|
|
84
|
+
): ByteArray {
|
|
85
|
+
val variableHeader = mutableListOf<Byte>()
|
|
86
|
+
variableHeader.addAll(encodeString(PROTOCOL_NAME).toList())
|
|
87
|
+
variableHeader.add(MQTTProtocolLevel.V311)
|
|
88
|
+
var flags = 0
|
|
89
|
+
if (cleanSession) flags = flags or MQTTConnectFlags.CLEAN_SESSION
|
|
90
|
+
if (username != null) flags = flags or MQTTConnectFlags.USERNAME
|
|
91
|
+
if (password != null) flags = flags or MQTTConnectFlags.PASSWORD
|
|
92
|
+
variableHeader.add(flags.toByte())
|
|
93
|
+
variableHeader.add((keepalive shr 8).toByte())
|
|
94
|
+
variableHeader.add((keepalive and 0xFF).toByte())
|
|
95
|
+
|
|
96
|
+
val payload = mutableListOf<Byte>()
|
|
97
|
+
payload.addAll(encodeString(clientId).toList())
|
|
98
|
+
username?.let { payload.addAll(encodeString(it).toList()) }
|
|
99
|
+
password?.let { payload.addAll(encodeString(it).toList()) }
|
|
100
|
+
|
|
101
|
+
val remLen = variableHeader.size + payload.size
|
|
102
|
+
val fixed = mutableListOf<Byte>()
|
|
103
|
+
fixed.add(MQTTMessageType.CONNECT)
|
|
104
|
+
fixed.addAll(encodeRemainingLength(remLen).toList())
|
|
105
|
+
|
|
106
|
+
return (fixed + variableHeader + payload).toByteArray()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun buildConnack(returnCode: Int = MQTTConnAckCode.ACCEPTED): ByteArray {
|
|
110
|
+
return byteArrayOf(
|
|
111
|
+
MQTTMessageType.CONNACK,
|
|
112
|
+
*encodeRemainingLength(2),
|
|
113
|
+
0x00,
|
|
114
|
+
returnCode.toByte()
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse CONNACK variable header. Returns Pair(sessionPresent, returnCode).
|
|
120
|
+
*/
|
|
121
|
+
fun parseConnack(data: ByteArray, offset: Int = 0): Pair<Boolean, Int> {
|
|
122
|
+
if (offset + 2 > data.size) throw IllegalArgumentException("Insufficient data for CONNACK")
|
|
123
|
+
val flags = data[offset].toInt() and 0xFF
|
|
124
|
+
val rc = data[offset + 1].toInt() and 0xFF
|
|
125
|
+
return ((flags and 0x01) != 0) to rc
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fun buildPublish(
|
|
129
|
+
topic: String,
|
|
130
|
+
payload: ByteArray,
|
|
131
|
+
packetId: Int? = null,
|
|
132
|
+
qos: Int = 0,
|
|
133
|
+
retain: Boolean = false
|
|
134
|
+
): ByteArray {
|
|
135
|
+
var msgType = MQTTMessageType.PUBLISH.toInt()
|
|
136
|
+
if (qos > 0) msgType = msgType or (qos shl 1)
|
|
137
|
+
if (retain) msgType = msgType or 0x01
|
|
138
|
+
|
|
139
|
+
val vh = mutableListOf<Byte>()
|
|
140
|
+
vh.addAll(encodeString(topic).toList())
|
|
141
|
+
if (qos > 0 && packetId != null) {
|
|
142
|
+
vh.add((packetId shr 8).toByte())
|
|
143
|
+
vh.add((packetId and 0xFF).toByte())
|
|
144
|
+
}
|
|
145
|
+
val vhArr = vh.toByteArray()
|
|
146
|
+
val pl = vhArr + payload
|
|
147
|
+
val remLen = pl.size
|
|
148
|
+
return byteArrayOf(
|
|
149
|
+
msgType.toByte(),
|
|
150
|
+
*encodeRemainingLength(remLen),
|
|
151
|
+
*pl
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fun buildPuback(packetId: Int): ByteArray {
|
|
156
|
+
return byteArrayOf(
|
|
157
|
+
MQTTMessageType.PUBACK,
|
|
158
|
+
*encodeRemainingLength(2),
|
|
159
|
+
(packetId shr 8).toByte(),
|
|
160
|
+
(packetId and 0xFF).toByte()
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fun parsePuback(data: ByteArray, offset: Int = 0): Int {
|
|
165
|
+
if (offset + 2 > data.size) throw IllegalArgumentException("Insufficient data for PUBACK")
|
|
166
|
+
return ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parse PUBLISH payload (after fixed header).
|
|
171
|
+
* Returns (topic, packetId?, payload, newOffset). packetId only for QoS > 0.
|
|
172
|
+
*/
|
|
173
|
+
fun parsePublish(data: ByteArray, offset: Int, qos: Int): Triple<String, Int?, ByteArray, Int> {
|
|
174
|
+
var off = offset
|
|
175
|
+
val (topic, next) = decodeString(data, off)
|
|
176
|
+
off = next
|
|
177
|
+
var pid: Int? = null
|
|
178
|
+
if (qos > 0) {
|
|
179
|
+
if (off + 2 > data.size) throw IllegalArgumentException("Insufficient data for PUBLISH packet ID")
|
|
180
|
+
pid = ((data[off].toInt() and 0xFF) shl 8) or (data[off + 1].toInt() and 0xFF)
|
|
181
|
+
off += 2
|
|
182
|
+
}
|
|
183
|
+
val payload = data.copyOfRange(off, data.size)
|
|
184
|
+
return Triple(topic, pid, payload, data.size)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fun buildSubscribe(packetId: Int, topic: String, qos: Int = 0): ByteArray {
|
|
188
|
+
val vh = byteArrayOf((packetId shr 8).toByte(), (packetId and 0xFF).toByte())
|
|
189
|
+
val pl = encodeString(topic) + byteArrayOf((qos and 0x03).toByte())
|
|
190
|
+
val rem = vh.size + pl.size
|
|
191
|
+
return byteArrayOf(
|
|
192
|
+
(MQTTMessageType.SUBSCRIBE.toInt() or 0x02).toByte(),
|
|
193
|
+
*encodeRemainingLength(rem),
|
|
194
|
+
*vh,
|
|
195
|
+
*pl
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fun buildSuback(packetId: Int, returnCode: Int = 0): ByteArray {
|
|
200
|
+
return byteArrayOf(
|
|
201
|
+
MQTTMessageType.SUBACK,
|
|
202
|
+
*encodeRemainingLength(3),
|
|
203
|
+
(packetId shr 8).toByte(),
|
|
204
|
+
(packetId and 0xFF).toByte(),
|
|
205
|
+
returnCode.toByte()
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fun parseSuback(data: ByteArray, offset: Int = 0): Triple<Int, Int, Int> {
|
|
210
|
+
if (offset + 3 > data.size) throw IllegalArgumentException("Insufficient data for SUBACK")
|
|
211
|
+
val pid = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
|
212
|
+
val rc = data[offset + 2].toInt() and 0xFF
|
|
213
|
+
return Triple(pid, rc, offset + 3)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fun buildUnsubscribe(packetId: Int, topics: List<String>): ByteArray {
|
|
217
|
+
val vh = byteArrayOf((packetId shr 8).toByte(), (packetId and 0xFF).toByte())
|
|
218
|
+
val plList = topics.flatMap { encodeString(it).toList() }
|
|
219
|
+
val pl = ByteArray(plList.size) { plList[it] }
|
|
220
|
+
val rem = vh.size + pl.size
|
|
221
|
+
return byteArrayOf(
|
|
222
|
+
(MQTTMessageType.UNSUBSCRIBE.toInt() or 0x02).toByte(),
|
|
223
|
+
*encodeRemainingLength(rem),
|
|
224
|
+
*vh,
|
|
225
|
+
*pl
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fun buildUnsuback(packetId: Int): ByteArray {
|
|
230
|
+
return byteArrayOf(
|
|
231
|
+
MQTTMessageType.UNSUBACK,
|
|
232
|
+
*encodeRemainingLength(2),
|
|
233
|
+
(packetId shr 8).toByte(),
|
|
234
|
+
(packetId and 0xFF).toByte()
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fun buildPingreq(): ByteArray {
|
|
239
|
+
return byteArrayOf(MQTTMessageType.PINGREQ, *encodeRemainingLength(0))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fun buildPingresp(): ByteArray {
|
|
243
|
+
return byteArrayOf(MQTTMessageType.PINGRESP, *encodeRemainingLength(0))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fun buildDisconnect(): ByteArray {
|
|
247
|
+
return byteArrayOf(MQTTMessageType.DISCONNECT, *encodeRemainingLength(0))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.mqtt
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MQTT packet types and constants. Matches MQTTD protocol.py / protocol_v5.py.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
object MQTTMessageType {
|
|
8
|
+
const val CONNECT: Byte = 0x10
|
|
9
|
+
const val CONNACK: Byte = 0x20
|
|
10
|
+
const val PUBLISH: Byte = 0x30
|
|
11
|
+
const val PUBACK: Byte = 0x40
|
|
12
|
+
const val PUBREC: Byte = 0x50
|
|
13
|
+
const val PUBREL: Byte = 0x62
|
|
14
|
+
const val PUBCOMP: Byte = 0x70
|
|
15
|
+
const val SUBSCRIBE: Byte = 0x82
|
|
16
|
+
const val SUBACK: Byte = 0x90
|
|
17
|
+
const val UNSUBSCRIBE: Byte = 0xA2
|
|
18
|
+
const val UNSUBACK: Byte = 0xB0
|
|
19
|
+
const val PINGREQ: Byte = 0xC0
|
|
20
|
+
const val PINGRESP: Byte = 0xD0
|
|
21
|
+
const val DISCONNECT: Byte = 0xE0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
object MQTTConnectFlags {
|
|
25
|
+
const val USERNAME: Int = 0x80
|
|
26
|
+
const val PASSWORD: Int = 0x40
|
|
27
|
+
const val WILL_RETAIN: Int = 0x20
|
|
28
|
+
const val WILL_QOS1: Int = 0x08
|
|
29
|
+
const val WILL_QOS2: Int = 0x18
|
|
30
|
+
const val WILL_FLAG: Int = 0x04
|
|
31
|
+
const val CLEAN_SESSION: Int = 0x02
|
|
32
|
+
const val RESERVED: Int = 0x01
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
object MQTTConnAckCode {
|
|
36
|
+
const val ACCEPTED: Int = 0x00
|
|
37
|
+
const val UNACCEPTABLE_PROTOCOL: Int = 0x01
|
|
38
|
+
const val IDENTIFIER_REJECTED: Int = 0x02
|
|
39
|
+
const val SERVER_UNAVAILABLE: Int = 0x03
|
|
40
|
+
const val BAD_USERNAME_PASSWORD: Int = 0x04
|
|
41
|
+
const val NOT_AUTHORIZED: Int = 0x05
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
object MQTTProtocolLevel {
|
|
45
|
+
const val V311: Byte = 0x04
|
|
46
|
+
const val V5: Byte = 0x05
|
|
47
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.quic
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import kotlinx.coroutines.delay
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ngtcp2-based QUIC client implementation for Android.
|
|
8
|
+
* Replaces QuicClientStub when ngtcp2 is built and linked via JNI.
|
|
9
|
+
*
|
|
10
|
+
* Build Requirements:
|
|
11
|
+
* - ngtcp2 native library (libngtcp2_client.so)
|
|
12
|
+
* - nghttp3 static library (libnghttp3.a)
|
|
13
|
+
* - OpenSSL 3.0+ or BoringSSL for TLS 1.3
|
|
14
|
+
* - Android NDK r25+
|
|
15
|
+
* - Android API 21+ (Android 5.0+)
|
|
16
|
+
*/
|
|
17
|
+
class NGTCP2Client : QuicClient {
|
|
18
|
+
|
|
19
|
+
companion object {
|
|
20
|
+
private const val TAG = "NGTCP2Client"
|
|
21
|
+
private var nativeAvailable: Boolean = false
|
|
22
|
+
|
|
23
|
+
init {
|
|
24
|
+
nativeAvailable = try {
|
|
25
|
+
System.loadLibrary("ngtcp2_client")
|
|
26
|
+
true
|
|
27
|
+
} catch (e: UnsatisfiedLinkError) {
|
|
28
|
+
false
|
|
29
|
+
} catch (e: Exception) {
|
|
30
|
+
false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!nativeAvailable) {
|
|
34
|
+
Log.w(TAG, "ngtcp2_client native library not available")
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fun isAvailable(): Boolean = nativeAvailable
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Native methods (implemented in ngtcp2_jni.cpp)
|
|
42
|
+
private external fun nativeCreateConnection(host: String, port: Int): Long
|
|
43
|
+
private external fun nativeConnect(connHandle: Long): Int
|
|
44
|
+
private external fun nativeOpenStream(connHandle: Long): Long
|
|
45
|
+
private external fun nativeWriteStream(connHandle: Long, streamId: Long, data: ByteArray): Int
|
|
46
|
+
private external fun nativeReadStream(connHandle: Long, streamId: Long): ByteArray?
|
|
47
|
+
private external fun nativeClose(connHandle: Long)
|
|
48
|
+
private external fun nativeIsConnected(connHandle: Long): Boolean
|
|
49
|
+
internal external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
|
|
50
|
+
internal external fun nativeGetLastError(connHandle: Long): String
|
|
51
|
+
|
|
52
|
+
// Connection state
|
|
53
|
+
private var connHandle: Long = 0
|
|
54
|
+
private var isConnected: Boolean = false
|
|
55
|
+
private val streams = mutableMapOf<Long, NGTCP2Stream>()
|
|
56
|
+
|
|
57
|
+
override suspend fun connect(host: String, port: Int) {
|
|
58
|
+
if (!isAvailable()) {
|
|
59
|
+
throw IllegalStateException("ngtcp2 native library is not loaded")
|
|
60
|
+
}
|
|
61
|
+
if (isConnected) {
|
|
62
|
+
throw IllegalStateException("Already connected")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Create native connection
|
|
66
|
+
connHandle = nativeCreateConnection(host, port)
|
|
67
|
+
if (connHandle == 0L) {
|
|
68
|
+
throw IllegalStateException("Failed to create QUIC connection")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Connect to server
|
|
72
|
+
val result = nativeConnect(connHandle)
|
|
73
|
+
if (result != 0) {
|
|
74
|
+
throw Exception("QUIC connection failed: ${nativeGetLastError(connHandle)}")
|
|
75
|
+
}
|
|
76
|
+
isConnected = true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override suspend fun openStream(): QuicStream {
|
|
80
|
+
if (!isConnected) {
|
|
81
|
+
throw IllegalStateException("Not connected")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
val streamId = nativeOpenStream(connHandle)
|
|
85
|
+
if (streamId < 0L) {
|
|
86
|
+
throw IllegalStateException("Failed to open QUIC stream: ${nativeGetLastError(connHandle)}")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
val stream = NGTCP2Stream(connHandle, streamId, this)
|
|
90
|
+
streams[streamId] = stream
|
|
91
|
+
|
|
92
|
+
return stream
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override suspend fun close() {
|
|
96
|
+
if (!isConnected) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Close all streams
|
|
101
|
+
streams.values.forEach { stream ->
|
|
102
|
+
try {
|
|
103
|
+
stream.close()
|
|
104
|
+
} catch (e: Exception) {
|
|
105
|
+
// Ignore errors during stream close
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
streams.clear()
|
|
109
|
+
|
|
110
|
+
// Close native connection
|
|
111
|
+
nativeClose(connHandle)
|
|
112
|
+
connHandle = 0
|
|
113
|
+
|
|
114
|
+
isConnected = false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Internal method called by NGTCP2Stream to write data
|
|
119
|
+
*/
|
|
120
|
+
internal suspend fun writeStreamData(streamId: Long, data: ByteArray): Int {
|
|
121
|
+
if (!isConnected) {
|
|
122
|
+
throw IllegalStateException("Not connected")
|
|
123
|
+
}
|
|
124
|
+
return nativeWriteStream(connHandle, streamId, data)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Internal method called to read data from stream
|
|
129
|
+
*/
|
|
130
|
+
internal suspend fun readStreamData(streamId: Long): ByteArray? {
|
|
131
|
+
if (!isConnected) {
|
|
132
|
+
throw IllegalStateException("Not connected")
|
|
133
|
+
}
|
|
134
|
+
return nativeReadStream(connHandle, streamId)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* ngtcp2-based QUIC stream implementation
|
|
140
|
+
*/
|
|
141
|
+
internal class NGTCP2Stream(
|
|
142
|
+
private val connHandle: Long,
|
|
143
|
+
override val streamId: Long,
|
|
144
|
+
private val client: NGTCP2Client
|
|
145
|
+
) : QuicStream {
|
|
146
|
+
|
|
147
|
+
private var isClosed: Boolean = false
|
|
148
|
+
|
|
149
|
+
override suspend fun read(maxBytes: Int): ByteArray {
|
|
150
|
+
if (isClosed) {
|
|
151
|
+
throw IllegalStateException("Stream is closed")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
while (!isClosed) {
|
|
155
|
+
val data = client.readStreamData(streamId)
|
|
156
|
+
if (data != null && data.isNotEmpty()) {
|
|
157
|
+
return data
|
|
158
|
+
}
|
|
159
|
+
delay(5)
|
|
160
|
+
}
|
|
161
|
+
return ByteArray(0)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
override suspend fun write(data: ByteArray) {
|
|
165
|
+
if (isClosed) {
|
|
166
|
+
throw IllegalStateException("Stream is closed")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Write data to native stream
|
|
170
|
+
val result = client.writeStreamData(streamId, data)
|
|
171
|
+
if (result != 0) {
|
|
172
|
+
throw Exception("Failed to write to stream: ${client.nativeGetLastError(connHandle)}")
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
override suspend fun close() {
|
|
177
|
+
if (isClosed) {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
client.nativeCloseStream(connHandle, streamId)
|
|
182
|
+
isClosed = true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.quic
|
|
2
|
+
|
|
3
|
+
import ai.annadata.mqttquic.transport.MockStreamBuffer
|
|
4
|
+
import ai.annadata.mqttquic.transport.MockStreamReader
|
|
5
|
+
import ai.annadata.mqttquic.transport.MockStreamWriter
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stub QUIC stream. Phase 2: replace with ngtcp2-backed implementation.
|
|
9
|
+
*/
|
|
10
|
+
class QuicStreamStub(
|
|
11
|
+
override val streamId: Long,
|
|
12
|
+
private val buffer: MockStreamBuffer
|
|
13
|
+
) : QuicStream {
|
|
14
|
+
|
|
15
|
+
private val reader = MockStreamReader(buffer)
|
|
16
|
+
private val writer = MockStreamWriter(buffer)
|
|
17
|
+
|
|
18
|
+
override suspend fun read(maxBytes: Int): ByteArray = reader.read(maxBytes)
|
|
19
|
+
|
|
20
|
+
override suspend fun write(data: ByteArray) {
|
|
21
|
+
writer.write(data)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override suspend fun close() {
|
|
25
|
+
writer.close()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stub QUIC client. connect() succeeds without network; openStream() returns stub stream.
|
|
31
|
+
* Phase 2 proper: ngtcp2 + JNI, DatagramSocket for UDP.
|
|
32
|
+
* Pass initialReadData (e.g. CONNACK) to simulate server response for testing.
|
|
33
|
+
*/
|
|
34
|
+
class QuicClientStub(private val initialReadData: List<Byte> = emptyList()) : QuicClient {
|
|
35
|
+
|
|
36
|
+
private var buffer: MockStreamBuffer? = null
|
|
37
|
+
private var streamId = 0L
|
|
38
|
+
|
|
39
|
+
override suspend fun connect(host: String, port: Int) {
|
|
40
|
+
buffer = MockStreamBuffer(initialReadData.toByteArray())
|
|
41
|
+
streamId = 0L
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override suspend fun openStream(): QuicStream {
|
|
45
|
+
val buf = buffer ?: error("Not connected")
|
|
46
|
+
val s = QuicStreamStub(streamId, buf)
|
|
47
|
+
streamId++
|
|
48
|
+
return s
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override suspend fun close() {
|
|
52
|
+
buffer = null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.quic
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QUIC stream: one bidirectional stream per MQTT connection.
|
|
5
|
+
* Phase 2: ngtcp2 client + single stream.
|
|
6
|
+
*/
|
|
7
|
+
interface QuicStream {
|
|
8
|
+
val streamId: Long
|
|
9
|
+
suspend fun read(maxBytes: Int): ByteArray
|
|
10
|
+
suspend fun write(data: ByteArray)
|
|
11
|
+
suspend fun close()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* QUIC client: connect, TLS handshake, open one bidirectional stream.
|
|
16
|
+
*/
|
|
17
|
+
interface QuicClient {
|
|
18
|
+
suspend fun connect(host: String, port: Int)
|
|
19
|
+
suspend fun openStream(): QuicStream
|
|
20
|
+
suspend fun close()
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.transport
|
|
2
|
+
|
|
3
|
+
import ai.annadata.mqttquic.quic.QuicStream
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MQTTStreamReader over QUIC stream. Mirrors NGTCP2StreamReader.
|
|
7
|
+
*/
|
|
8
|
+
class QUICStreamReader(private val stream: QuicStream) : MQTTStreamReader {
|
|
9
|
+
|
|
10
|
+
override suspend fun read(maxBytes: Int): ByteArray = stream.read(maxBytes)
|
|
11
|
+
|
|
12
|
+
override suspend fun readexactly(n: Int): ByteArray {
|
|
13
|
+
val acc = mutableListOf<Byte>()
|
|
14
|
+
while (acc.size < n) {
|
|
15
|
+
val chunk = stream.read(n - acc.size)
|
|
16
|
+
if (chunk.isEmpty()) throw IllegalArgumentException("readexactly")
|
|
17
|
+
acc.addAll(chunk.toList())
|
|
18
|
+
}
|
|
19
|
+
return acc.toByteArray()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* MQTTStreamWriter over QUIC stream. Mirrors NGTCP2StreamWriter.
|
|
25
|
+
*/
|
|
26
|
+
class QUICStreamWriter(private val stream: QuicStream) : MQTTStreamWriter {
|
|
27
|
+
|
|
28
|
+
override suspend fun write(data: ByteArray) {
|
|
29
|
+
stream.write(data)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override suspend fun drain() {}
|
|
33
|
+
|
|
34
|
+
override suspend fun close() {
|
|
35
|
+
stream.close()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.transport
|
|
2
|
+
|
|
3
|
+
import kotlinx.coroutines.delay
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* StreamReader-like: read(n), readexactly(n).
|
|
7
|
+
* Phase 2 implements over QUIC stream.
|
|
8
|
+
*/
|
|
9
|
+
interface MQTTStreamReader {
|
|
10
|
+
suspend fun read(maxBytes: Int): ByteArray
|
|
11
|
+
suspend fun readexactly(n: Int): ByteArray
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* StreamWriter-like: write(data), drain(), close().
|
|
16
|
+
*/
|
|
17
|
+
interface MQTTStreamWriter {
|
|
18
|
+
suspend fun write(data: ByteArray)
|
|
19
|
+
suspend fun drain()
|
|
20
|
+
suspend fun close()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* In-memory buffer for mock read/write (Phase 1 unit tests).
|
|
25
|
+
*/
|
|
26
|
+
class MockStreamBuffer(initialReadData: ByteArray = ByteArray(0)) {
|
|
27
|
+
var readBuffer = initialReadData.toMutableList()
|
|
28
|
+
private set
|
|
29
|
+
val writeBuffer = mutableListOf<Byte>()
|
|
30
|
+
var isClosed = false
|
|
31
|
+
private set
|
|
32
|
+
|
|
33
|
+
fun appendRead(data: ByteArray) {
|
|
34
|
+
readBuffer.addAll(data.toList())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fun consumeWrite(): ByteArray {
|
|
38
|
+
val d = writeBuffer.toByteArray()
|
|
39
|
+
writeBuffer.clear()
|
|
40
|
+
return d
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun close() {
|
|
44
|
+
isClosed = true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mock reader over MockStreamBuffer.
|
|
50
|
+
*/
|
|
51
|
+
class MockStreamReader(private val buffer: MockStreamBuffer) : MQTTStreamReader {
|
|
52
|
+
|
|
53
|
+
override suspend fun read(maxBytes: Int): ByteArray {
|
|
54
|
+
if (buffer.isClosed && buffer.readBuffer.isEmpty()) return ByteArray(0)
|
|
55
|
+
val n = minOf(maxBytes, buffer.readBuffer.size)
|
|
56
|
+
if (n == 0) {
|
|
57
|
+
delay(1)
|
|
58
|
+
return read(maxBytes)
|
|
59
|
+
}
|
|
60
|
+
val out = buffer.readBuffer.take(n).toByteArray()
|
|
61
|
+
repeat(n) { buffer.readBuffer.removeAt(0) }
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override suspend fun readexactly(n: Int): ByteArray {
|
|
66
|
+
val acc = mutableListOf<Byte>()
|
|
67
|
+
while (acc.size < n) {
|
|
68
|
+
if (buffer.isClosed) throw IllegalArgumentException("stream closed")
|
|
69
|
+
val chunk = read(n - acc.size)
|
|
70
|
+
if (chunk.isEmpty()) throw IllegalArgumentException("readexactly")
|
|
71
|
+
acc.addAll(chunk.toList())
|
|
72
|
+
}
|
|
73
|
+
return acc.toByteArray()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mock writer over MockStreamBuffer.
|
|
79
|
+
*/
|
|
80
|
+
class MockStreamWriter(private val buffer: MockStreamBuffer) : MQTTStreamWriter {
|
|
81
|
+
|
|
82
|
+
override suspend fun write(data: ByteArray) {
|
|
83
|
+
buffer.writeBuffer.addAll(data.toList())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override suspend fun drain() {}
|
|
87
|
+
|
|
88
|
+
override suspend fun close() {
|
|
89
|
+
buffer.close()
|
|
90
|
+
}
|
|
91
|
+
}
|