@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.
Files changed (64) hide show
  1. package/README.md +399 -0
  2. package/android/NGTCP2_BUILD_INSTRUCTIONS.md +319 -0
  3. package/android/build-nghttp3.sh +182 -0
  4. package/android/build-ngtcp2.sh +289 -0
  5. package/android/build-openssl.sh +302 -0
  6. package/android/build.gradle +75 -0
  7. package/android/gradle.properties +3 -0
  8. package/android/proguard-rules.pro +2 -0
  9. package/android/settings.gradle +1 -0
  10. package/android/src/main/AndroidManifest.xml +1 -0
  11. package/android/src/main/assets/mqttquic_ca.pem +5 -0
  12. package/android/src/main/cpp/CMakeLists.txt +157 -0
  13. package/android/src/main/cpp/ngtcp2_jni.cpp +928 -0
  14. package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +232 -0
  15. package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +339 -0
  16. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Properties.kt +250 -0
  17. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Protocol.kt +281 -0
  18. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5ReasonCodes.kt +109 -0
  19. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +249 -0
  20. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTTypes.kt +47 -0
  21. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +184 -0
  22. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +54 -0
  23. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +21 -0
  24. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +37 -0
  25. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +91 -0
  26. package/android/src/test/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocolTest.kt +92 -0
  27. package/dist/esm/definitions.d.ts +66 -0
  28. package/dist/esm/definitions.d.ts.map +1 -0
  29. package/dist/esm/definitions.js +2 -0
  30. package/dist/esm/definitions.js.map +1 -0
  31. package/dist/esm/index.d.ts +5 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +7 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/web.d.ts +28 -0
  36. package/dist/esm/web.d.ts.map +1 -0
  37. package/dist/esm/web.js +183 -0
  38. package/dist/esm/web.js.map +1 -0
  39. package/dist/plugin.cjs.js +217 -0
  40. package/dist/plugin.cjs.js.map +1 -0
  41. package/dist/plugin.js +215 -0
  42. package/dist/plugin.js.map +1 -0
  43. package/ios/MqttQuicPlugin.podspec +34 -0
  44. package/ios/NGTCP2_BUILD_INSTRUCTIONS.md +302 -0
  45. package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +343 -0
  46. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Properties.swift +280 -0
  47. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Protocol.swift +333 -0
  48. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5ReasonCodes.swift +113 -0
  49. package/ios/Sources/MqttQuicPlugin/MQTT/MQTTProtocol.swift +322 -0
  50. package/ios/Sources/MqttQuicPlugin/MQTT/MQTTTypes.swift +54 -0
  51. package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +229 -0
  52. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.h +29 -0
  53. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.mm +865 -0
  54. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Client.swift +262 -0
  55. package/ios/Sources/MqttQuicPlugin/QUIC/QuicClientStub.swift +66 -0
  56. package/ios/Sources/MqttQuicPlugin/QUIC/QuicTypes.swift +23 -0
  57. package/ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem +5 -0
  58. package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +50 -0
  59. package/ios/Sources/MqttQuicPlugin/Transport/StreamTransport.swift +105 -0
  60. package/ios/Tests/MQTTProtocolTests.swift +82 -0
  61. package/ios/build-nghttp3.sh +173 -0
  62. package/ios/build-ngtcp2.sh +278 -0
  63. package/ios/build-openssl.sh +405 -0
  64. 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
+ }