@cnhongwei/react-native-mqtt 0.0.0-stage → 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 (58) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +169 -2
  3. package/README.zh-CN.md +100 -0
  4. package/ReactNativeMqtt.podspec +28 -0
  5. package/THIRD_PARTY_NOTICES.md +17 -0
  6. package/android/build.gradle +70 -0
  7. package/android/consumer-rules.pro +10 -0
  8. package/android/src/main/AndroidManifest.xml +2 -0
  9. package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttOptionsParser.kt +176 -0
  10. package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttSession.kt +1006 -0
  11. package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoLogger.java +44 -0
  12. package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoMqttTransport.kt +399 -0
  13. package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttModule.kt +619 -0
  14. package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttPackage.kt +31 -0
  15. package/ios/ReactNativeMqtt.h +15 -0
  16. package/ios/ReactNativeMqtt.mm +1184 -0
  17. package/ios/ReactNativeMqttCocoaTransport.swift +782 -0
  18. package/ios/ReactNativeMqttConnAckProbe.swift +87 -0
  19. package/ios/ReactNativeMqttSession.swift +420 -0
  20. package/lib/module/NativeReactNativeMqtt.js +5 -0
  21. package/lib/module/NativeReactNativeMqtt.js.map +1 -0
  22. package/lib/module/client.js +555 -0
  23. package/lib/module/client.js.map +1 -0
  24. package/lib/module/errors.js +20 -0
  25. package/lib/module/errors.js.map +1 -0
  26. package/lib/module/helpers.js +116 -0
  27. package/lib/module/helpers.js.map +1 -0
  28. package/lib/module/index.js +8 -0
  29. package/lib/module/index.js.map +1 -0
  30. package/lib/module/package.json +1 -0
  31. package/lib/module/types.js +2 -0
  32. package/lib/module/types.js.map +1 -0
  33. package/lib/module/validation.js +306 -0
  34. package/lib/module/validation.js.map +1 -0
  35. package/lib/typescript/package.json +1 -0
  36. package/lib/typescript/src/NativeReactNativeMqtt.d.ts +154 -0
  37. package/lib/typescript/src/NativeReactNativeMqtt.d.ts.map +1 -0
  38. package/lib/typescript/src/client.d.ts +3 -0
  39. package/lib/typescript/src/client.d.ts.map +1 -0
  40. package/lib/typescript/src/errors.d.ts +18 -0
  41. package/lib/typescript/src/errors.d.ts.map +1 -0
  42. package/lib/typescript/src/helpers.d.ts +5 -0
  43. package/lib/typescript/src/helpers.d.ts.map +1 -0
  44. package/lib/typescript/src/index.d.ts +9 -0
  45. package/lib/typescript/src/index.d.ts.map +1 -0
  46. package/lib/typescript/src/types.d.ts +161 -0
  47. package/lib/typescript/src/types.d.ts.map +1 -0
  48. package/lib/typescript/src/validation.d.ts +13 -0
  49. package/lib/typescript/src/validation.d.ts.map +1 -0
  50. package/package.json +188 -4
  51. package/react-native.config.js +1 -0
  52. package/src/NativeReactNativeMqtt.ts +173 -0
  53. package/src/client.ts +859 -0
  54. package/src/errors.ts +51 -0
  55. package/src/helpers.ts +151 -0
  56. package/src/index.ts +30 -0
  57. package/src/types.ts +184 -0
  58. package/src/validation.ts +735 -0
@@ -0,0 +1,619 @@
1
+ package com.cnhongwei.reactnativemqtt
2
+
3
+ import android.util.Base64
4
+ import com.facebook.react.bridge.Arguments
5
+ import com.facebook.react.bridge.Promise
6
+ import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.bridge.ReadableArray
8
+ import com.facebook.react.bridge.ReactApplicationContext
9
+ import com.facebook.react.bridge.WritableMap
10
+ import com.facebook.react.bridge.WritableArray
11
+ import java.util.concurrent.atomic.AtomicLong
12
+ import java.util.concurrent.ConcurrentHashMap
13
+ import java.util.ArrayDeque
14
+
15
+ internal fun decodeStrictBase64(encoded: String): ByteArray {
16
+ require(isStrictBase64Shape(encoded)) { "payloadBase64 contains invalid characters or padding" }
17
+ val decoded = ByteArray(encoded.length / 4 * 3 - encoded.count { it == '=' })
18
+ var outputIndex = 0
19
+ var inputIndex = 0
20
+ while (inputIndex < encoded.length) {
21
+ val first = base64Value(encoded[inputIndex])
22
+ val second = base64Value(encoded[inputIndex + 1])
23
+ val third = if (encoded[inputIndex + 2] == '=') 0 else base64Value(encoded[inputIndex + 2])
24
+ val fourth = if (encoded[inputIndex + 3] == '=') 0 else base64Value(encoded[inputIndex + 3])
25
+ require(first >= 0 && second >= 0 && third >= 0 && fourth >= 0) { "payloadBase64 is invalid" }
26
+ val combined = (first shl 18) or (second shl 12) or (third shl 6) or fourth
27
+ if (outputIndex < decoded.size) decoded[outputIndex++] = (combined ushr 16).toByte()
28
+ if (outputIndex < decoded.size) decoded[outputIndex++] = (combined ushr 8).toByte()
29
+ if (outputIndex < decoded.size) decoded[outputIndex++] = combined.toByte()
30
+ inputIndex += 4
31
+ }
32
+ require(encodeStrictBase64(decoded) == encoded) {
33
+ "payloadBase64 has non-canonical padding or trailing bits"
34
+ }
35
+ return decoded
36
+ }
37
+
38
+ private fun encodeStrictBase64(bytes: ByteArray): String {
39
+ val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
40
+ val output = StringBuilder((bytes.size + 2) / 3 * 4)
41
+ var index = 0
42
+ while (index < bytes.size) {
43
+ val first = bytes[index].toInt() and 0xff
44
+ val second = if (index + 1 < bytes.size) bytes[index + 1].toInt() and 0xff else 0
45
+ val third = if (index + 2 < bytes.size) bytes[index + 2].toInt() and 0xff else 0
46
+ output.append(alphabet[first ushr 2])
47
+ output.append(alphabet[((first and 3) shl 4) or (second ushr 4)])
48
+ output.append(if (index + 1 < bytes.size) alphabet[((second and 15) shl 2) or (third ushr 6)] else '=')
49
+ output.append(if (index + 2 < bytes.size) alphabet[third and 63] else '=')
50
+ index += 3
51
+ }
52
+ return output.toString()
53
+ }
54
+
55
+ internal fun isStrictBase64Shape(encoded: String): Boolean =
56
+ encoded.length % 4 == 0 && encoded.matches(Regex("[A-Za-z0-9+/]*={0,2}")) &&
57
+ (if (encoded.endsWith("==")) base64Value(encoded[encoded.length - 3]) and 15 == 0
58
+ else if (encoded.endsWith("=")) base64Value(encoded[encoded.length - 2]) and 3 == 0
59
+ else true)
60
+
61
+ private fun base64Value(value: Char): Int =
62
+ when (value) {
63
+ in 'A'..'Z' -> value - 'A'
64
+ in 'a'..'z' -> value - 'a' + 26
65
+ in '0'..'9' -> value - '0' + 52
66
+ '+' -> 62
67
+ '/' -> 63
68
+ else -> -1
69
+ }
70
+
71
+ internal fun findMqttSessionException(error: Throwable): MqttSessionException? {
72
+ var current: Throwable? = error
73
+ repeat(8) {
74
+ when (current) {
75
+ is MqttSessionException -> return current
76
+ null -> return null
77
+ else -> current = current.cause
78
+ }
79
+ }
80
+ return null
81
+ }
82
+
83
+ class ReactNativeMqttModule(reactContext: ReactApplicationContext) :
84
+ NativeReactNativeMqttSpec(reactContext) {
85
+
86
+ private val batchIds = AtomicLong(0)
87
+ private val streamEpochs = ConcurrentHashMap<String, Long>()
88
+ private val batchLedger = BatchLedger()
89
+ private val deliveryStates = ConcurrentHashMap<String, DeliveryState>()
90
+ private lateinit var registry: MqttSessionRegistry
91
+
92
+ init {
93
+ registry = MqttSessionRegistry(onMessage = { instanceId, generation, message ->
94
+ val delivery = deliveryStates[instanceId] ?: return@MqttSessionRegistry
95
+ if (!delivery.enqueue(generation, message)) {
96
+ val droppedMessages = delivery.recordDrop()
97
+ if (delivery.takeOverflowDiagnostic()) {
98
+ val diagnostic = Arguments.createMap().apply {
99
+ putString("instanceId", instanceId)
100
+ putDouble("generation", generation.toDouble())
101
+ putString("code", "QUEUE_OVERFLOW")
102
+ putString("message", "Message delivery queue is full")
103
+ putString("operation", "onMessageBatch")
104
+ putMap("metrics", Arguments.createMap().apply {
105
+ putInt("queuedMessages", delivery.queuedMessages())
106
+ putInt("queuedBytes", delivery.queuedBytes().coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
107
+ putInt("pendingOperations", registry.pendingOperations(instanceId))
108
+ putLong("droppedMessages", droppedMessages)
109
+ putInt("reconnectAttempts", registry.reconnectAttempts(instanceId).coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
110
+ })
111
+ }
112
+ emitOnDiagnostic(diagnostic)
113
+ }
114
+ if (delivery.overflowPolicy == "disconnect") {
115
+ registry.disconnect(instanceId).whenComplete { snapshot, error ->
116
+ if (error == null && snapshot != null) emitOnSessionSnapshot(snapshotMap(snapshot))
117
+ }
118
+ }
119
+ return@MqttSessionRegistry
120
+ }
121
+ emitNextBatch(instanceId)
122
+ }, onTransportLost = { instanceId, generation, disconnect ->
123
+ if (disconnect != null) {
124
+ emitOnDiagnostic(Arguments.createMap().apply {
125
+ putString("instanceId", instanceId)
126
+ putDouble("generation", generation.toDouble())
127
+ putString("code", "SERVER_DISCONNECT")
128
+ putString("message", "Broker disconnected the MQTT session")
129
+ putString("operation", "disconnect")
130
+ putInt("reasonCode", disconnect.reasonCode)
131
+ disconnect.reasonString?.let { putString("reasonString", it) }
132
+ })
133
+ }
134
+ }, onSessionSnapshot = { snapshot ->
135
+ emitOnSessionSnapshot(snapshotMap(snapshot))
136
+ })
137
+ }
138
+
139
+ private fun emitNextBatch(instanceId: String) {
140
+ val delivery = deliveryStates[instanceId] ?: return
141
+ val next = delivery.startNext() ?: return
142
+ val epoch = streamEpochs.computeIfAbsent(instanceId) { 1L }
143
+ val batchId = batchIds.incrementAndGet()
144
+ val token = BatchToken(epoch, batchId)
145
+ if (!batchLedger.reserve(instanceId, token)) {
146
+ delivery.rollbackNext(next)
147
+ return
148
+ }
149
+ val messages = Arguments.createArray().apply {
150
+ next.forEach { (generation, message) ->
151
+ pushMap(Arguments.createMap().apply {
152
+ putString("instanceId", instanceId)
153
+ putDouble("generation", generation.toDouble())
154
+ putString("topic", message.topic)
155
+ putString("payloadBase64", Base64.encodeToString(message.payload, Base64.NO_WRAP))
156
+ putInt("qos", message.qos)
157
+ putBoolean("retained", message.retained)
158
+ if (message.userProperties.isNotEmpty()) {
159
+ putArray("userProperties", Arguments.createArray().apply {
160
+ message.userProperties.forEach { property ->
161
+ pushMap(Arguments.createMap().apply {
162
+ putString("key", property["key"])
163
+ putString("value", property["value"])
164
+ })
165
+ }
166
+ })
167
+ }
168
+ message.contentType?.let { putString("contentType", it) }
169
+ message.responseTopic?.let { putString("responseTopic", it) }
170
+ message.correlationData?.let { putString("correlationDataBase64", Base64.encodeToString(it, Base64.NO_WRAP)) }
171
+ message.messageExpiryIntervalSeconds?.let { putDouble("messageExpiryIntervalSeconds", it.toDouble()) }
172
+ message.payloadFormatIndicator?.let { putInt("payloadFormatIndicator", it) }
173
+ })
174
+ }
175
+ }
176
+ val batch = Arguments.createMap().apply {
177
+ putString("instanceId", instanceId)
178
+ putDouble("streamEpoch", epoch.toDouble())
179
+ putDouble("batchId", batchId.toDouble())
180
+ putArray("messages", messages)
181
+ }
182
+ emitOnMessageBatch(batch)
183
+ }
184
+
185
+ private fun snapshotMap(snapshot: Map<String, Any?>): WritableMap = Arguments.createMap().apply {
186
+ putString("instanceId", snapshot["instanceId"] as? String ?: "")
187
+ putDouble("revision", (snapshot["revision"] as? Number)?.toDouble() ?: 0.0)
188
+ putDouble("generation", (snapshot["generation"] as? Number)?.toDouble() ?: 0.0)
189
+ putString("state", snapshot["state"] as? String ?: "closed")
190
+ val ack = snapshot["lastConnAck"] as? Map<*, *>
191
+ if (ack == null) putNull("lastConnAck") else putMap("lastConnAck", Arguments.createMap().apply {
192
+ putBoolean("sessionPresent", ack["sessionPresent"] as? Boolean ?: false)
193
+ putInt("reasonCode", (ack["reasonCode"] as? Number)?.toInt() ?: 0)
194
+ (ack["reasonString"] as? String)?.let { putString("reasonString", it) }
195
+ (ack["assignedClientId"] as? String)?.let { putString("assignedClientId", it) }
196
+ val properties = ack["userProperties"] as? List<*>
197
+ if (properties != null) putArray("userProperties", Arguments.createArray().apply {
198
+ properties.forEach { property ->
199
+ val pair = property as? Map<*, *> ?: return@forEach
200
+ pushMap(Arguments.createMap().apply {
201
+ putString("key", pair["key"] as? String ?: "")
202
+ putString("value", pair["value"] as? String ?: "")
203
+ })
204
+ }
205
+ })
206
+ })
207
+ putDouble("subscriptionsRevision", (snapshot["subscriptionsRevision"] as? Number)?.toDouble() ?: 0.0)
208
+ putString("subscriptionsStatus", snapshot["subscriptionsStatus"] as? String ?: "ready")
209
+ putString("deliveryStatus", snapshot["deliveryStatus"] as? String ?: "active")
210
+ }
211
+
212
+ override fun getCapabilities(promise: Promise) {
213
+ promise.resolve(mapOf(
214
+ "wireVersion" to 1,
215
+ "platform" to "android",
216
+ "protocolVersions" to listOf(4, 5),
217
+ "transports" to listOf("tcp", "tls", "ws", "wss"),
218
+ "supportsSessionPresent" to true,
219
+ "supportsDuplicateUserProperties" to true
220
+ ))
221
+ }
222
+
223
+ private fun <T> forward(
224
+ promise: Promise,
225
+ future: java.util.concurrent.CompletableFuture<T>,
226
+ operation: String? = null,
227
+ ) {
228
+ future.whenComplete { value, error ->
229
+ if (error == null) {
230
+ promise.resolve(bridgeValue(value))
231
+ }
232
+ else {
233
+ val cause = findMqttSessionException(error) ?: error
234
+ val userInfo = Arguments.createMap()
235
+ if (cause is MqttSessionException) {
236
+ (cause.operation ?: operation)?.let { userInfo.putString("operation", it) }
237
+ cause.generation?.let { userInfo.putDouble("generation", it.toDouble()) }
238
+ // Keep reasonCode=0: presence, not truthiness, carries its meaning.
239
+ cause.reasonCode?.let { userInfo.putInt("reasonCode", it) }
240
+ cause.reasonString?.let { userInfo.putString("reasonString", it) }
241
+ cause.outcome?.let { userInfo.putString("outcome", it) }
242
+ }
243
+ promise.reject(
244
+ if (cause is MqttSessionException) cause.errorCode else "ERR_UNKNOWN",
245
+ cause.message,
246
+ cause,
247
+ userInfo
248
+ )
249
+ }
250
+ }
251
+ }
252
+
253
+ private fun emitOperationDiagnostic(instanceId: String, operation: String, error: Throwable) {
254
+ val cause = findMqttSessionException(error)
255
+ emitOnDiagnostic(Arguments.createMap().apply {
256
+ putString("instanceId", instanceId)
257
+ putString("code", cause?.errorCode ?: "ERR_UNKNOWN")
258
+ putString("message", cause?.message ?: error.message ?: "Operation failed")
259
+ putString("operation", cause?.operation ?: operation)
260
+ cause?.generation?.let { putDouble("generation", it.toDouble()) }
261
+ cause?.reasonCode?.let { putInt("reasonCode", it) }
262
+ cause?.reasonString?.let { putString("reasonString", it) }
263
+ cause?.outcome?.let { putString("outcome", it) }
264
+ })
265
+ }
266
+
267
+ private fun bridgeValue(value: Any?): Any? = when (value) {
268
+ is Map<*, *> -> Arguments.createMap().also { map ->
269
+ value.forEach { (key, nested) -> putBridgeValue(map, key.toString(), nested) }
270
+ }
271
+ is List<*> -> Arguments.createArray().also { array ->
272
+ value.forEach { nested -> pushBridgeValue(array, nested) }
273
+ }
274
+ else -> value
275
+ }
276
+
277
+ private fun putBridgeValue(map: WritableMap, key: String, value: Any?) {
278
+ when (value) {
279
+ null -> map.putNull(key)
280
+ is Boolean -> map.putBoolean(key, value)
281
+ is Number -> map.putDouble(key, value.toDouble())
282
+ is String -> map.putString(key, value)
283
+ is Map<*, *> -> map.putMap(key, bridgeValue(value) as WritableMap)
284
+ is List<*> -> map.putArray(key, bridgeValue(value) as WritableArray)
285
+ else -> map.putString(key, value.toString())
286
+ }
287
+ }
288
+
289
+ private fun pushBridgeValue(array: WritableArray, value: Any?) {
290
+ when (value) {
291
+ null -> array.pushNull()
292
+ is Boolean -> array.pushBoolean(value)
293
+ is Number -> array.pushDouble(value.toDouble())
294
+ is String -> array.pushString(value)
295
+ is Map<*, *> -> array.pushMap(bridgeValue(value) as WritableMap)
296
+ is List<*> -> array.pushArray(bridgeValue(value) as WritableArray)
297
+ else -> array.pushString(value.toString())
298
+ }
299
+ }
300
+
301
+ override fun openSession(instanceId: String, options: ReadableMap, promise: Promise) {
302
+ MqttOptionsParser.parse(options).fold(
303
+ onSuccess = { parsed ->
304
+ val open = registry.open(instanceId, parsed)
305
+ forward(promise, open.thenApply { snapshot ->
306
+ deliveryStates.putIfAbsent(instanceId, DeliveryState(parsed))
307
+ snapshotMap(snapshot)
308
+ }, "openSession")
309
+ },
310
+ onFailure = { promise.reject("ERR_INVALID_OPTIONS", it.message) }
311
+ )
312
+ }
313
+ override fun connect(instanceId: String, promise: Promise) {
314
+ val future = registry.connect(instanceId)
315
+ future.whenComplete { _, error ->
316
+ if (error != null) emitOperationDiagnostic(instanceId, "connect", error)
317
+ }
318
+ forward(promise, future, "connect")
319
+ }
320
+ override fun disconnect(instanceId: String, promise: Promise) = forward(promise, registry.disconnect(instanceId), "disconnect")
321
+ override fun close(instanceId: String, promise: Promise) {
322
+ val future = registry.close(instanceId)
323
+ future.whenComplete { _, _ ->
324
+ batchLedger.clear(instanceId)
325
+ streamEpochs.remove(instanceId)
326
+ deliveryStates.remove(instanceId)?.clear()
327
+ }
328
+ forward(promise, future, "close")
329
+ }
330
+ override fun getSnapshot(instanceId: String, promise: Promise) = forward(promise, registry.snapshot(instanceId), "getSnapshot")
331
+ override fun setSubscription(instanceId: String, filter: String, options: ReadableMap, intentRevision: Double, promise: Promise) {
332
+ if (!validTopicFilter(filter)) {
333
+ promise.reject("ERR_INVALID_OPTIONS", "topic filter is invalid")
334
+ return
335
+ }
336
+ if (!hasOnlyKeys(options, "qos", "noLocal", "retainAsPublished", "retainHandling")) {
337
+ promise.reject("ERR_INVALID_OPTIONS", "unsupported subscription option")
338
+ return
339
+ }
340
+ val revision = revision(intentRevision, promise) ?: return
341
+ val qos = integer(options, "qos", 1, 0, 2, promise) ?: return
342
+ val noLocal = boolean(options, "noLocal", false, promise) ?: return
343
+ val retainAsPublished = boolean(options, "retainAsPublished", false, promise) ?: return
344
+ val retainHandling = integer(options, "retainHandling", 0, 0, 2, promise) ?: return
345
+ forward(promise, registry.setSubscription(instanceId, filter, qos, noLocal, retainAsPublished, retainHandling, revision), "setSubscription")
346
+ }
347
+ override fun removeSubscription(instanceId: String, filter: String, intentRevision: Double, promise: Promise) {
348
+ if (!validTopicFilter(filter)) {
349
+ promise.reject("ERR_INVALID_OPTIONS", "topic filter is invalid")
350
+ return
351
+ }
352
+ val revision = revision(intentRevision, promise) ?: return
353
+ forward(promise, registry.removeSubscription(instanceId, filter, revision), "removeSubscription")
354
+ }
355
+ override fun awaitSubscriptions(instanceId: String, generation: Double, desiredRevision: Double, promise: Promise) {
356
+ val generationValue = revision(generation, promise) ?: return
357
+ val desiredValue = revision(desiredRevision, promise) ?: return
358
+ forward(promise, registry.awaitSubscriptions(instanceId, generationValue, desiredValue), "awaitSubscriptions")
359
+ }
360
+ override fun publish(instanceId: String, request: ReadableMap, promise: Promise) {
361
+ if (!hasOnlyKeys(request, "topic", "payloadBase64", "qos", "retained", "userProperties", "contentType", "responseTopic", "correlationDataBase64", "messageExpiryIntervalSeconds", "payloadFormatIndicator")) {
362
+ promise.reject("ERR_INVALID_OPTIONS", "unsupported publish option")
363
+ return
364
+ }
365
+ val topic = request.getString("topic")
366
+ val encoded = request.getString("payloadBase64")
367
+ if (topic.isNullOrEmpty() || !validTopicName(topic) || encoded == null) {
368
+ promise.reject("ERR_INVALID_OPTIONS", "publish topic and payloadBase64 are invalid")
369
+ return
370
+ }
371
+ val payload = try {
372
+ decodeStrictBase64(encoded)
373
+ } catch (error: IllegalArgumentException) {
374
+ promise.reject("ERR_INVALID_PAYLOAD", "payloadBase64 is invalid", error)
375
+ return
376
+ }
377
+ val qos = integer(request, "qos", 1, 0, 2, promise) ?: return
378
+ val retained = boolean(request, "retained", false, promise) ?: return
379
+ val properties = publishProperties(request, promise) ?: return
380
+ forward(promise, registry.publish(instanceId, topic, payload, qos, retained, properties), "publish")
381
+ }
382
+ override fun ackMessageBatch(instanceId: String, streamEpoch: Double, batchId: Double, promise: Promise) {
383
+ if (!streamEpoch.isFinite() || !batchId.isFinite() || streamEpoch < 0 || batchId < 0 || streamEpoch != streamEpoch.toLong().toDouble() || batchId != batchId.toLong().toDouble()) {
384
+ promise.reject("ERR_INVALID_OPTIONS", "ACK token must contain non-negative integers")
385
+ return
386
+ }
387
+ val token = BatchToken(streamEpoch.toLong(), batchId.toLong())
388
+ if (batchLedger.acknowledge(instanceId, token)) {
389
+ deliveryStates[instanceId]?.acknowledge()
390
+ emitNextBatch(instanceId)
391
+ }
392
+ promise.resolve(null)
393
+ }
394
+
395
+ override fun invalidate() {
396
+ batchLedger.clear()
397
+ deliveryStates.values.forEach { it.clear() }
398
+ deliveryStates.clear()
399
+ registry.invalidate()
400
+ super.invalidate()
401
+ }
402
+
403
+ companion object {
404
+ const val NAME = NativeReactNativeMqttSpec.NAME
405
+ }
406
+
407
+ private fun revision(value: Double, promise: Promise): Long? {
408
+ if (!value.isFinite() || value < 0 || value != value.toLong().toDouble()) {
409
+ promise.reject("ERR_INVALID_OPTIONS", "revision must be a non-negative integer")
410
+ return null
411
+ }
412
+ return value.toLong()
413
+ }
414
+
415
+ private fun integer(options: ReadableMap, key: String, default: Int, min: Int, max: Int, promise: Promise): Int? {
416
+ val value = if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else default.toDouble()
417
+ if (!value.isFinite() || value != value.toInt().toDouble() || value < min || value > max) {
418
+ promise.reject("ERR_INVALID_OPTIONS", "$key must be an integer in $min..$max")
419
+ return null
420
+ }
421
+ return value.toInt()
422
+ }
423
+
424
+ private fun boolean(options: ReadableMap, key: String, default: Boolean, promise: Promise): Boolean? {
425
+ if (!options.hasKey(key) || options.isNull(key)) return default
426
+ return try {
427
+ options.getBoolean(key)
428
+ } catch (error: Throwable) {
429
+ promise.reject("ERR_INVALID_OPTIONS", "$key must be a boolean", error)
430
+ null
431
+ }
432
+ }
433
+
434
+ private fun publishProperties(options: ReadableMap, promise: Promise): MqttPublishProperties? {
435
+ fun string(key: String): String? {
436
+ if (!options.hasKey(key) || options.isNull(key)) return null
437
+ return try { options.getString(key) }
438
+ catch (error: Throwable) {
439
+ promise.reject("ERR_INVALID_OPTIONS", "$key must be a string", error)
440
+ null
441
+ }
442
+ }
443
+ val contentType = string("contentType")
444
+ if (options.hasKey("contentType") && !options.isNull("contentType") && contentType == null) return null
445
+ val responseTopic = string("responseTopic")
446
+ if (options.hasKey("responseTopic") && !options.isNull("responseTopic") && responseTopic == null) return null
447
+ if (responseTopic != null && !validTopicName(responseTopic)) {
448
+ promise.reject("ERR_INVALID_OPTIONS", "responseTopic is not a valid topic name")
449
+ return null
450
+ }
451
+ val correlation = string("correlationDataBase64")?.let {
452
+ try { decodeStrictBase64(it) } catch (error: IllegalArgumentException) {
453
+ promise.reject("ERR_INVALID_PAYLOAD", "correlationDataBase64 is invalid", error)
454
+ return null
455
+ }
456
+ }
457
+ val expiry = if (!options.hasKey("messageExpiryIntervalSeconds") || options.isNull("messageExpiryIntervalSeconds")) null else {
458
+ val value = options.getDouble("messageExpiryIntervalSeconds")
459
+ if (!value.isFinite() || value != value.toLong().toDouble() || value !in 0.0..4294967295.0) {
460
+ promise.reject("ERR_INVALID_OPTIONS", "messageExpiryIntervalSeconds must be an integer in 0..4294967295")
461
+ return null
462
+ }
463
+ value.toLong()
464
+ }
465
+ val format = if (!options.hasKey("payloadFormatIndicator") || options.isNull("payloadFormatIndicator")) null else {
466
+ val value = options.getDouble("payloadFormatIndicator")
467
+ if (!value.isFinite() || value != value.toInt().toDouble() || value !in 0.0..1.0) {
468
+ promise.reject("ERR_INVALID_OPTIONS", "payloadFormatIndicator must be 0 or 1")
469
+ return null
470
+ }
471
+ value.toInt()
472
+ }
473
+ val userProperties: List<Pair<String, String>> = if (!options.hasKey("userProperties") || options.isNull("userProperties")) emptyList() else try {
474
+ val array: ReadableArray = options.getArray("userProperties") ?: throw IllegalArgumentException("userProperties must be an array")
475
+ val result = mutableListOf<Pair<String, String>>()
476
+ for (index in 0 until array.size()) {
477
+ val item = array.getMap(index) ?: throw IllegalArgumentException("userProperties[$index] must be an object")
478
+ val key = item.getString("key") ?: throw IllegalArgumentException("userProperties[$index].key must be a string")
479
+ val value = item.getString("value") ?: throw IllegalArgumentException("userProperties[$index].value must be a string")
480
+ require(key.toByteArray(Charsets.UTF_8).size <= 65535 && value.toByteArray(Charsets.UTF_8).size <= 65535) { "userProperties[$index] exceeds the MQTT UTF-8 string limit" }
481
+ result.add(key to value)
482
+ }
483
+ result
484
+ } catch (error: Throwable) {
485
+ promise.reject("ERR_INVALID_OPTIONS", error.message ?: "userProperties is invalid", error)
486
+ return null
487
+ }
488
+ return MqttPublishProperties(userProperties, contentType, responseTopic, correlation, expiry, format)
489
+ }
490
+
491
+ private fun hasOnlyKeys(options: ReadableMap, vararg allowed: String): Boolean {
492
+ val accepted = allowed.toSet()
493
+ val iterator = options.keySetIterator()
494
+ while (iterator.hasNextKey()) if (iterator.nextKey() !in accepted) return false
495
+ return true
496
+ }
497
+
498
+ private fun validTopicName(topic: String): Boolean = topic.isNotEmpty() && validMqttUtf8(topic) && !topic.contains('+') && !topic.contains('#') && topic.toByteArray(Charsets.UTF_8).size <= 65535
499
+
500
+ private fun validTopicFilter(filter: String): Boolean {
501
+ if (filter.isEmpty() || !validMqttUtf8(filter) || filter.startsWith("\$share/") || filter.toByteArray(Charsets.UTF_8).size > 65535) return false
502
+ val levels = filter.split('/')
503
+ return levels.withIndex().all { (index, level) ->
504
+ (!level.contains('+') || level == "+") && (!level.contains('#') || (level == "#" && index == levels.lastIndex))
505
+ }
506
+ }
507
+
508
+ private fun validMqttUtf8(value: String): Boolean = isValidMqttUtf8(value)
509
+
510
+ }
511
+
512
+ internal data class BatchToken(val streamEpoch: Long, val batchId: Long)
513
+
514
+ internal class DeliveryState(private val options: ParsedMqttOptions) {
515
+ private val queue = ArrayDeque<Pair<Long, MqttMessageData>>()
516
+ private var bufferedBytes = 0L
517
+ private var inFlight = false
518
+ private var inFlightMessages = 0
519
+ private var inFlightBytes = 0
520
+ private var droppedMessageCount = 0L
521
+ private var overflowDiagnosticEmitted = false
522
+
523
+ val overflowPolicy: String get() = options.overflowPolicy
524
+
525
+ @Synchronized
526
+ fun enqueue(generation: Long, message: MqttMessageData): Boolean {
527
+ if (message.payload.size > options.maxMessageBytes) return false
528
+ val bufferedMessages = queue.size + inFlightMessages
529
+ if (bufferedMessages >= options.maxQueuedMessages ||
530
+ bufferedBytes + message.payload.size + inFlightBytes > options.maxQueuedBytes) return false
531
+ queue.addLast(generation to message)
532
+ bufferedBytes += message.payload.size
533
+ return true
534
+ }
535
+
536
+ @Synchronized
537
+ fun startNext(): List<Pair<Long, MqttMessageData>>? {
538
+ if (inFlight || queue.isEmpty()) return null
539
+ val next = mutableListOf<Pair<Long, MqttMessageData>>()
540
+ var batchBytes = 0
541
+ while (queue.isNotEmpty() && next.size < options.batchMaxMessages) {
542
+ val candidate = queue.first()
543
+ val candidateBytes = candidate.second.payload.size
544
+ if (next.isNotEmpty() && batchBytes + candidateBytes > options.batchMaxBytes) break
545
+ queue.removeFirst()
546
+ next += candidate
547
+ batchBytes += candidateBytes
548
+ bufferedBytes -= candidateBytes
549
+ }
550
+ inFlight = true
551
+ inFlightMessages = next.size
552
+ inFlightBytes = batchBytes
553
+ return next
554
+ }
555
+
556
+ @Synchronized
557
+ fun rollbackNext(next: List<Pair<Long, MqttMessageData>>) {
558
+ if (!inFlight) return
559
+ inFlight = false
560
+ inFlightMessages = 0
561
+ inFlightBytes = 0
562
+ next.asReversed().forEach { item ->
563
+ queue.addFirst(item)
564
+ bufferedBytes += item.second.payload.size
565
+ }
566
+ }
567
+
568
+ @Synchronized
569
+ fun acknowledge() {
570
+ inFlight = false
571
+ inFlightMessages = 0
572
+ inFlightBytes = 0
573
+ overflowDiagnosticEmitted = false
574
+ }
575
+
576
+ @Synchronized
577
+ fun queuedMessages(): Int = queue.size + inFlightMessages
578
+
579
+ @Synchronized
580
+ fun queuedBytes(): Long = bufferedBytes + inFlightBytes
581
+
582
+ @Synchronized
583
+ fun recordDrop(): Long {
584
+ droppedMessageCount += 1
585
+ return droppedMessageCount
586
+ }
587
+
588
+ @Synchronized
589
+ fun takeOverflowDiagnostic(): Boolean {
590
+ if (overflowDiagnosticEmitted) return false
591
+ overflowDiagnosticEmitted = true
592
+ return true
593
+ }
594
+
595
+ @Synchronized
596
+ fun clear() {
597
+ queue.clear()
598
+ bufferedBytes = 0
599
+ inFlight = false
600
+ inFlightMessages = 0
601
+ inFlightBytes = 0
602
+ droppedMessageCount = 0
603
+ overflowDiagnosticEmitted = false
604
+ }
605
+ }
606
+
607
+ internal class BatchLedger {
608
+ private val outstanding = ConcurrentHashMap<String, BatchToken>()
609
+
610
+ fun reserve(instanceId: String, token: BatchToken): Boolean =
611
+ outstanding.putIfAbsent(instanceId, token) == null
612
+
613
+ fun acknowledge(instanceId: String, token: BatchToken): Boolean =
614
+ outstanding.remove(instanceId, token)
615
+
616
+ fun clear(instanceId: String) = outstanding.remove(instanceId)
617
+
618
+ fun clear() = outstanding.clear()
619
+ }
@@ -0,0 +1,31 @@
1
+ package com.cnhongwei.reactnativemqtt
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+ import java.util.HashMap
9
+
10
+ class ReactNativeMqttPackage : BaseReactPackage() {
11
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
12
+ return if (name == ReactNativeMqttModule.NAME) {
13
+ ReactNativeMqttModule(reactContext)
14
+ } else {
15
+ null
16
+ }
17
+ }
18
+
19
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
20
+ mapOf(
21
+ ReactNativeMqttModule.NAME to ReactModuleInfo(
22
+ name = ReactNativeMqttModule.NAME,
23
+ className = ReactNativeMqttModule.NAME,
24
+ canOverrideExistingModule = false,
25
+ needsEagerInit = false,
26
+ isCxxModule = false,
27
+ isTurboModule = true
28
+ )
29
+ )
30
+ }
31
+ }
@@ -0,0 +1,15 @@
1
+ #import <ReactNativeMqttSpec/ReactNativeMqttSpec.h>
2
+
3
+ @interface ReactNativeMqtt : NativeReactNativeMqttSpecBase <NativeReactNativeMqttSpec>
4
+ {
5
+ dispatch_queue_t _sessionQueue;
6
+ NSMutableDictionary<NSString *, NSDictionary *> *_sessions;
7
+ NSMutableDictionary<NSString *, NSDictionary *> *_iosBatchLedger;
8
+ NSMutableDictionary<NSString *, NSNumber *> *_iosStreamEpochs;
9
+ NSMutableDictionary<NSString *, NSMutableDictionary<NSString *, NSMutableArray *> *> *_iosSubscriptionQueues;
10
+ NSUInteger _iosBatchId;
11
+ NSUInteger _nextOperationId;
12
+ BOOL _invalidated;
13
+ }
14
+
15
+ @end