@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.
- package/LICENSE +20 -0
- package/README.md +169 -2
- package/README.zh-CN.md +100 -0
- package/ReactNativeMqtt.podspec +28 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/android/build.gradle +70 -0
- package/android/consumer-rules.pro +10 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttOptionsParser.kt +176 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttSession.kt +1006 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoLogger.java +44 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoMqttTransport.kt +399 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttModule.kt +619 -0
- package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttPackage.kt +31 -0
- package/ios/ReactNativeMqtt.h +15 -0
- package/ios/ReactNativeMqtt.mm +1184 -0
- package/ios/ReactNativeMqttCocoaTransport.swift +782 -0
- package/ios/ReactNativeMqttConnAckProbe.swift +87 -0
- package/ios/ReactNativeMqttSession.swift +420 -0
- package/lib/module/NativeReactNativeMqtt.js +5 -0
- package/lib/module/NativeReactNativeMqtt.js.map +1 -0
- package/lib/module/client.js +555 -0
- package/lib/module/client.js.map +1 -0
- package/lib/module/errors.js +20 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/helpers.js +116 -0
- package/lib/module/helpers.js.map +1 -0
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/validation.js +306 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeReactNativeMqtt.d.ts +154 -0
- package/lib/typescript/src/NativeReactNativeMqtt.d.ts.map +1 -0
- package/lib/typescript/src/client.d.ts +3 -0
- package/lib/typescript/src/client.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +18 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/helpers.d.ts +5 -0
- package/lib/typescript/src/helpers.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +9 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +161 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +13 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +188 -4
- package/react-native.config.js +1 -0
- package/src/NativeReactNativeMqtt.ts +173 -0
- package/src/client.ts +859 -0
- package/src/errors.ts +51 -0
- package/src/helpers.ts +151 -0
- package/src/index.ts +30 -0
- package/src/types.ts +184 -0
- 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
|