@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,1006 @@
|
|
|
1
|
+
package com.cnhongwei.reactnativemqtt
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.CompletableFuture
|
|
4
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
5
|
+
import java.util.concurrent.ExecutorService
|
|
6
|
+
import java.util.concurrent.Executors
|
|
7
|
+
import java.util.concurrent.RejectedExecutionException
|
|
8
|
+
import java.util.concurrent.ScheduledExecutorService
|
|
9
|
+
import java.util.concurrent.TimeUnit
|
|
10
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
11
|
+
import java.util.concurrent.ThreadLocalRandom
|
|
12
|
+
|
|
13
|
+
internal interface MqttTimer {
|
|
14
|
+
fun cancel()
|
|
15
|
+
fun isDone(): Boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
internal interface MqttScheduler {
|
|
19
|
+
fun schedule(delayMs: Long, task: () -> Unit): MqttTimer
|
|
20
|
+
fun shutdown()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private class ExecutorMqttScheduler : MqttScheduler {
|
|
24
|
+
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { runnable ->
|
|
25
|
+
Thread(runnable, "react-native-mqtt-deadlines").apply { isDaemon = true }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override fun schedule(delayMs: Long, task: () -> Unit): MqttTimer {
|
|
29
|
+
val future = executor.schedule(task, delayMs, TimeUnit.MILLISECONDS)
|
|
30
|
+
return object : MqttTimer {
|
|
31
|
+
override fun cancel() { future.cancel(false) }
|
|
32
|
+
override fun isDone(): Boolean = future.isDone
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override fun shutdown() { executor.shutdownNow() }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
internal class MqttSession(
|
|
40
|
+
private val instanceId: String,
|
|
41
|
+
private val options: ParsedMqttOptions,
|
|
42
|
+
private val onMessage: (Long, MqttMessageData) -> Unit = { _, _ -> },
|
|
43
|
+
private val transportFactory: ((ParsedMqttOptions, (MqttMessageData) -> Unit) -> MqttTransport)? = null,
|
|
44
|
+
private val onTransportLost: (Long, MqttDisconnect?) -> Unit = { _, _ -> },
|
|
45
|
+
) {
|
|
46
|
+
private data class SubscriptionIntent(
|
|
47
|
+
val qos: Int,
|
|
48
|
+
val noLocal: Boolean,
|
|
49
|
+
val retainAsPublished: Boolean,
|
|
50
|
+
val retainHandling: Int,
|
|
51
|
+
val revision: Long,
|
|
52
|
+
var grantedQos: Int? = null,
|
|
53
|
+
)
|
|
54
|
+
private data class SubscriptionWaiter(
|
|
55
|
+
val generation: Long,
|
|
56
|
+
val desiredRevision: Long,
|
|
57
|
+
val future: CompletableFuture<Map<String, Any?>>,
|
|
58
|
+
)
|
|
59
|
+
private data class OperationLedgerEntry(
|
|
60
|
+
val operationId: Long,
|
|
61
|
+
val generation: Long,
|
|
62
|
+
val operation: String,
|
|
63
|
+
val topic: String?,
|
|
64
|
+
var settled: Boolean = false,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
private var revision = 0L
|
|
68
|
+
private var generation = 0L
|
|
69
|
+
private var state = "idle"
|
|
70
|
+
private var lastConnAck: Map<String, Any?>? = null
|
|
71
|
+
private var subscriptionsRevision = 0L
|
|
72
|
+
private var subscriptionsStatus = "ready"
|
|
73
|
+
private var transport: MqttTransport? = null
|
|
74
|
+
private var connecting: CompletableFuture<MqttConnAck>? = null
|
|
75
|
+
private var attemptId = 0L
|
|
76
|
+
private var stopRequested = false
|
|
77
|
+
private val desiredSubscriptions = LinkedHashMap<String, SubscriptionIntent>()
|
|
78
|
+
private val confirmedSubscriptions = LinkedHashMap<String, SubscriptionIntent>()
|
|
79
|
+
private val tombstones = LinkedHashMap<String, Long>()
|
|
80
|
+
private val subscriptionTails = mutableMapOf<String, CompletableFuture<Unit>>()
|
|
81
|
+
private var pendingSubscriptionOperations = 0
|
|
82
|
+
private val subscriptionWaiters = mutableListOf<SubscriptionWaiter>()
|
|
83
|
+
private val reconnectWaiters = mutableListOf<CompletableFuture<Map<String, Any?>>>()
|
|
84
|
+
private val pendingOperationFutures = mutableSetOf<CompletableFuture<*>>()
|
|
85
|
+
private val operationLedger = LinkedHashMap<Long, OperationLedgerEntry>()
|
|
86
|
+
private var nextOperationId = 0L
|
|
87
|
+
|
|
88
|
+
fun snapshot(): Map<String, Any?> = mapOf(
|
|
89
|
+
"instanceId" to instanceId,
|
|
90
|
+
"revision" to revision,
|
|
91
|
+
"generation" to generation,
|
|
92
|
+
"state" to state,
|
|
93
|
+
"lastConnAck" to lastConnAck,
|
|
94
|
+
"subscriptionsRevision" to subscriptionsRevision,
|
|
95
|
+
"subscriptionsStatus" to subscriptionsStatus,
|
|
96
|
+
"deliveryStatus" to "active",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
fun connect(serialExecutor: ExecutorService, forceAttempt: Boolean = false): CompletableFuture<Map<String, Any?>> {
|
|
100
|
+
if (state == "closed") return failed(MqttSessionException("ERR_DISPOSED", "Session is closed"))
|
|
101
|
+
if (state == "connected" && lastConnAck != null) return CompletableFuture.completedFuture(mapOf("snapshot" to snapshot(), "ack" to lastConnAck))
|
|
102
|
+
val existing = connecting
|
|
103
|
+
if (existing != null) return existing.thenApplyAsync({ mapOf("snapshot" to snapshot(), "ack" to lastConnAck) }, serialExecutor)
|
|
104
|
+
if (state == "reconnecting" && !forceAttempt) {
|
|
105
|
+
return CompletableFuture<Map<String, Any?>>().also { reconnectWaiters += it }
|
|
106
|
+
}
|
|
107
|
+
val reuseTransport = state == "reconnecting" && transport?.supportsInPlaceReconnect() == true
|
|
108
|
+
stopRequested = false
|
|
109
|
+
val currentAttempt = ++attemptId
|
|
110
|
+
state = "connecting"
|
|
111
|
+
revision += 1
|
|
112
|
+
val callbackGeneration = generation + 1
|
|
113
|
+
val currentTransport = if (reuseTransport) {
|
|
114
|
+
transport!!
|
|
115
|
+
} else {
|
|
116
|
+
if (transportFactory == null) {
|
|
117
|
+
// Paho owns the client callback across an in-place reconnect. Resolve
|
|
118
|
+
// the generation at delivery time so messages after reconnect are
|
|
119
|
+
// tagged with the current generation rather than the first one.
|
|
120
|
+
PahoMqttTransport(options) { message ->
|
|
121
|
+
if (state == "connected") onMessage(generation, message)
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
transportFactory.invoke(options) { message ->
|
|
125
|
+
if (state == "connected" && generation == callbackGeneration) onMessage(callbackGeneration, message)
|
|
126
|
+
}
|
|
127
|
+
}.also { transport = it }
|
|
128
|
+
}
|
|
129
|
+
currentTransport.setDisconnectedListener { reason ->
|
|
130
|
+
try {
|
|
131
|
+
serialExecutor.execute {
|
|
132
|
+
if (state == "connected" && generation == callbackGeneration) {
|
|
133
|
+
state = "reconnecting"
|
|
134
|
+
revision += 1
|
|
135
|
+
onTransportLost(callbackGeneration, reason)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (_: RejectedExecutionException) {
|
|
139
|
+
// Registry teardown owns the terminal state.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
val current = currentTransport.connect()
|
|
143
|
+
connecting = current
|
|
144
|
+
return current.handleAsync({ ack, error ->
|
|
145
|
+
if (error != null) {
|
|
146
|
+
val existing = findMqttSessionException(error)
|
|
147
|
+
if (existing != null) throw existing
|
|
148
|
+
throw MqttSessionException(
|
|
149
|
+
"ERR_CONNECTION_REFUSED",
|
|
150
|
+
error.message ?: "Connection failed",
|
|
151
|
+
error,
|
|
152
|
+
operation = "connect",
|
|
153
|
+
generation = callbackGeneration,
|
|
154
|
+
outcome = "unknown",
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
if (ack == null) throw MqttSessionException(
|
|
158
|
+
"ERR_CONNECTION_REFUSED",
|
|
159
|
+
"Connection completed without CONNACK",
|
|
160
|
+
operation = "connect",
|
|
161
|
+
generation = callbackGeneration,
|
|
162
|
+
outcome = "unknown",
|
|
163
|
+
)
|
|
164
|
+
if (stopRequested || currentAttempt != attemptId) throw MqttSessionException("ERR_CONNECTION_LOST", "Connection attempt was superseded")
|
|
165
|
+
state = "connected"
|
|
166
|
+
generation += 1
|
|
167
|
+
revision += 1
|
|
168
|
+
reconnectAttempts = 0
|
|
169
|
+
lastConnAck = mapOf("sessionPresent" to ack.sessionPresent, "reasonCode" to ack.reasonCode, "reasonString" to ack.reasonString, "assignedClientId" to ack.assignedClientId, "userProperties" to ack.userProperties).filterValues { it != null }
|
|
170
|
+
connecting = null
|
|
171
|
+
subscriptionsStatus = if (desiredSubscriptions.isEmpty()) "ready" else "pending"
|
|
172
|
+
if (!ack.sessionPresent) {
|
|
173
|
+
tombstones.clear()
|
|
174
|
+
if (desiredSubscriptions.isNotEmpty()) restoreSubscriptions(serialExecutor)
|
|
175
|
+
} else if (tombstones.isNotEmpty()) {
|
|
176
|
+
restoreTombstones(serialExecutor)
|
|
177
|
+
}
|
|
178
|
+
settleSubscriptionWaiters()
|
|
179
|
+
mapOf<String, Any?>("snapshot" to snapshot(), "ack" to lastConnAck).also { result ->
|
|
180
|
+
reconnectWaiters.removeAll { waiter -> waiter.complete(result); true }
|
|
181
|
+
}
|
|
182
|
+
}, serialExecutor)
|
|
183
|
+
.whenCompleteAsync({ _, _ -> if (currentAttempt == attemptId && state == "connecting") { state = "disconnected"; connecting = null } }, serialExecutor)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fun disconnect(serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
|
|
187
|
+
if (state == "idle" || state == "disconnected") return CompletableFuture.completedFuture(snapshot())
|
|
188
|
+
stopRequested = true
|
|
189
|
+
attemptId += 1
|
|
190
|
+
rejectReconnectWaiters(MqttSessionException("ERR_CONNECTION_LOST", "Connection was stopped"))
|
|
191
|
+
connecting?.cancel(true)
|
|
192
|
+
connecting = null
|
|
193
|
+
state = "disconnecting"
|
|
194
|
+
revision += 1
|
|
195
|
+
cancelPendingOperations(MqttSessionException("ERR_CONNECTION_LOST", "Operation was cancelled by disconnect"))
|
|
196
|
+
settleSubscriptionWaiters()
|
|
197
|
+
return (transport?.disconnect() ?: CompletableFuture.completedFuture(null)).thenApplyAsync({
|
|
198
|
+
state = "disconnected"
|
|
199
|
+
revision += 1
|
|
200
|
+
settleSubscriptionWaiters()
|
|
201
|
+
snapshot()
|
|
202
|
+
}, serialExecutor)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fun timeoutConnect() {
|
|
206
|
+
if (state != "connecting") return
|
|
207
|
+
state = "disconnected"
|
|
208
|
+
revision += 1
|
|
209
|
+
stopRequested = true
|
|
210
|
+
attemptId += 1
|
|
211
|
+
settleSubscriptionWaiters()
|
|
212
|
+
connecting?.cancel(true)
|
|
213
|
+
connecting = null
|
|
214
|
+
rejectReconnectWaiters(MqttSessionException("ERR_CONNECT_TIMEOUT", "Connection timed out"))
|
|
215
|
+
transport?.disconnect()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fun setSubscription(
|
|
219
|
+
filter: String,
|
|
220
|
+
qos: Int,
|
|
221
|
+
noLocal: Boolean,
|
|
222
|
+
retainAsPublished: Boolean,
|
|
223
|
+
retainHandling: Int,
|
|
224
|
+
intentRevision: Long,
|
|
225
|
+
serialExecutor: ExecutorService,
|
|
226
|
+
): CompletableFuture<Map<String, Any?>> {
|
|
227
|
+
if (state != "connected") return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session is not connected"))
|
|
228
|
+
subscriptionsStatus = "pending"
|
|
229
|
+
subscriptionsRevision = maxOf(subscriptionsRevision, intentRevision)
|
|
230
|
+
tombstones.remove(filter)
|
|
231
|
+
desiredSubscriptions[filter] = SubscriptionIntent(qos, noLocal, retainAsPublished, retainHandling, intentRevision)
|
|
232
|
+
val currentTransport = transport
|
|
233
|
+
if (currentTransport == null) {
|
|
234
|
+
desiredSubscriptions.remove(filter)
|
|
235
|
+
return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session transport is not connected"))
|
|
236
|
+
}
|
|
237
|
+
val operationGeneration = generation
|
|
238
|
+
pendingSubscriptionOperations += 1
|
|
239
|
+
val operation = enqueueFilterOperation(filter, serialExecutor) {
|
|
240
|
+
currentTransport.subscribe(filter, qos, noLocal, retainAsPublished, retainHandling)
|
|
241
|
+
}.exceptionally { error -> throw mapProtocolOperationError(error, "subscribe", operationGeneration) }
|
|
242
|
+
val result = operation.thenApplyAsync({ result ->
|
|
243
|
+
if (state != "connected" || generation != operationGeneration) {
|
|
244
|
+
throw MqttSessionException("ERR_CONNECTION_LOST", "Subscription generation changed")
|
|
245
|
+
}
|
|
246
|
+
val current = desiredSubscriptions[filter]
|
|
247
|
+
if (current == null || current.revision != intentRevision) {
|
|
248
|
+
throw MqttSessionException("ERR_OPERATION_SUPERSEDED", "Subscription intent was superseded")
|
|
249
|
+
}
|
|
250
|
+
if (result.grantedQos < 0) {
|
|
251
|
+
desiredSubscriptions.remove(filter)
|
|
252
|
+
subscriptionsStatus = "failed"
|
|
253
|
+
throw MqttSessionException(
|
|
254
|
+
"ERR_SUBSCRIBE_FAILED",
|
|
255
|
+
"Broker rejected subscription",
|
|
256
|
+
reasonCode = result.reasonCode,
|
|
257
|
+
outcome = "rejected",
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
current.grantedQos = result.grantedQos
|
|
261
|
+
confirmedSubscriptions[filter] = current.copy()
|
|
262
|
+
revision += 1
|
|
263
|
+
mapOf<String, Any?>("generation" to generation, "intentRevision" to intentRevision, "grantedQos" to result.grantedQos, "reasonCode" to result.reasonCode)
|
|
264
|
+
}, serialExecutor).whenCompleteAsync({ _, error ->
|
|
265
|
+
pendingSubscriptionOperations = (pendingSubscriptionOperations - 1).coerceAtLeast(0)
|
|
266
|
+
if (error != null) subscriptionsStatus = "failed"
|
|
267
|
+
else if (pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
|
|
268
|
+
settleSubscriptionWaiters()
|
|
269
|
+
}, serialExecutor)
|
|
270
|
+
return trackOperation(result, serialExecutor, "subscribe", filter, operationGeneration)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fun removeSubscription(filter: String, intentRevision: Long, serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
|
|
274
|
+
if (state == "closed") return failed(MqttSessionException("ERR_DISPOSED", "Session is closed"))
|
|
275
|
+
subscriptionsRevision = maxOf(subscriptionsRevision, intentRevision)
|
|
276
|
+
val previous = desiredSubscriptions.remove(filter)
|
|
277
|
+
tombstones[filter] = intentRevision
|
|
278
|
+
if (state != "connected") {
|
|
279
|
+
subscriptionsStatus = "pending"
|
|
280
|
+
return CompletableFuture.completedFuture(mapOf("intentRevision" to intentRevision))
|
|
281
|
+
}
|
|
282
|
+
subscriptionsStatus = "pending"
|
|
283
|
+
val currentTransport = transport
|
|
284
|
+
if (currentTransport == null) {
|
|
285
|
+
if (previous != null) desiredSubscriptions[filter] = previous
|
|
286
|
+
return CompletableFuture.completedFuture(mapOf("intentRevision" to intentRevision))
|
|
287
|
+
}
|
|
288
|
+
val operationGeneration = generation
|
|
289
|
+
pendingSubscriptionOperations += 1
|
|
290
|
+
val operation = enqueueFilterOperation(filter, serialExecutor) {
|
|
291
|
+
currentTransport.unsubscribe(filter)
|
|
292
|
+
}.exceptionally { error -> throw mapProtocolOperationError(error, "unsubscribe", operationGeneration) }
|
|
293
|
+
val result = operation.thenApplyAsync({ reasonCode ->
|
|
294
|
+
if (state != "connected" || generation != operationGeneration) {
|
|
295
|
+
throw MqttSessionException("ERR_CONNECTION_LOST", "Unsubscription generation changed")
|
|
296
|
+
}
|
|
297
|
+
val current = desiredSubscriptions[filter]
|
|
298
|
+
if (current != null && current.revision > intentRevision) {
|
|
299
|
+
throw MqttSessionException("ERR_OPERATION_SUPERSEDED", "Unsubscription intent was superseded")
|
|
300
|
+
}
|
|
301
|
+
if (reasonCode >= 128) {
|
|
302
|
+
if (previous != null) desiredSubscriptions[filter] = previous
|
|
303
|
+
tombstones.remove(filter)
|
|
304
|
+
subscriptionsStatus = "failed"
|
|
305
|
+
throw MqttSessionException(
|
|
306
|
+
"ERR_UNSUBSCRIBE_FAILED",
|
|
307
|
+
"Broker rejected unsubscription",
|
|
308
|
+
reasonCode = reasonCode,
|
|
309
|
+
outcome = "rejected",
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
tombstones.remove(filter)
|
|
313
|
+
confirmedSubscriptions.remove(filter)
|
|
314
|
+
revision += 1
|
|
315
|
+
mapOf<String, Any?>("intentRevision" to intentRevision)
|
|
316
|
+
}, serialExecutor).whenCompleteAsync({ _, error ->
|
|
317
|
+
pendingSubscriptionOperations = (pendingSubscriptionOperations - 1).coerceAtLeast(0)
|
|
318
|
+
if (error != null) subscriptionsStatus = "failed"
|
|
319
|
+
else if (pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
|
|
320
|
+
settleSubscriptionWaiters()
|
|
321
|
+
}, serialExecutor)
|
|
322
|
+
return trackOperation(result, serialExecutor, "unsubscribe", filter, operationGeneration)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private fun restoreSubscriptions(serialExecutor: ExecutorService) {
|
|
326
|
+
val currentTransport = transport ?: return
|
|
327
|
+
val intents = desiredSubscriptions.toMap().entries.toList()
|
|
328
|
+
val batches = mutableListOf<List<Map.Entry<String, SubscriptionIntent>>>()
|
|
329
|
+
var currentBatch = mutableListOf<Map.Entry<String, SubscriptionIntent>>()
|
|
330
|
+
var currentBytes = 0
|
|
331
|
+
var restoreFailed = false
|
|
332
|
+
intents.forEach { entry ->
|
|
333
|
+
val estimatedBytes = entry.key.toByteArray(Charsets.UTF_8).size + 8
|
|
334
|
+
if (estimatedBytes > RESTORE_MAX_PACKET_BYTES) {
|
|
335
|
+
restoreFailed = true
|
|
336
|
+
return@forEach
|
|
337
|
+
}
|
|
338
|
+
if (currentBatch.isNotEmpty() &&
|
|
339
|
+
(currentBatch.size >= RESTORE_MAX_FILTERS || currentBytes + estimatedBytes > RESTORE_MAX_PACKET_BYTES)
|
|
340
|
+
) {
|
|
341
|
+
batches += currentBatch
|
|
342
|
+
currentBatch = mutableListOf()
|
|
343
|
+
currentBytes = 0
|
|
344
|
+
}
|
|
345
|
+
currentBatch += entry
|
|
346
|
+
currentBytes += estimatedBytes
|
|
347
|
+
}
|
|
348
|
+
if (currentBatch.isNotEmpty()) batches += currentBatch
|
|
349
|
+
|
|
350
|
+
var chain = CompletableFuture.completedFuture(Unit)
|
|
351
|
+
if (batches.isEmpty()) {
|
|
352
|
+
subscriptionsStatus = "failed"
|
|
353
|
+
revision += 1
|
|
354
|
+
settleSubscriptionWaiters()
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
batches.chunked(RESTORE_MAX_IN_FLIGHT).forEach { wave ->
|
|
358
|
+
chain = chain.handleAsync({ _, error ->
|
|
359
|
+
if (error != null) restoreFailed = true
|
|
360
|
+
Unit
|
|
361
|
+
}, serialExecutor).thenComposeAsync({
|
|
362
|
+
val operations = wave.map { restoreSubscriptionBatch(it, currentTransport, serialExecutor) }
|
|
363
|
+
CompletableFuture.allOf(*operations.toTypedArray()).thenApply { Unit }
|
|
364
|
+
}, serialExecutor)
|
|
365
|
+
}
|
|
366
|
+
chain.handleAsync({ _, error ->
|
|
367
|
+
if (error != null) restoreFailed = true
|
|
368
|
+
if (!restoreFailed && pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
|
|
369
|
+
else subscriptionsStatus = "failed"
|
|
370
|
+
revision += 1
|
|
371
|
+
settleSubscriptionWaiters()
|
|
372
|
+
Unit
|
|
373
|
+
}, serialExecutor)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private fun restoreTombstones(serialExecutor: ExecutorService) {
|
|
377
|
+
val currentTransport = transport ?: return
|
|
378
|
+
val removals = tombstones.toMap().entries.toList()
|
|
379
|
+
subscriptionsStatus = "pending"
|
|
380
|
+
pendingSubscriptionOperations += removals.size
|
|
381
|
+
removals.forEach { (filter, intentRevision) ->
|
|
382
|
+
val operationGeneration = generation
|
|
383
|
+
enqueueFilterOperation(filter, serialExecutor) { currentTransport.unsubscribe(filter) }
|
|
384
|
+
.thenApplyAsync({ reasonCode ->
|
|
385
|
+
if (state != "connected" || generation != operationGeneration) {
|
|
386
|
+
throw MqttSessionException("ERR_CONNECTION_LOST", "Tombstone restoration was interrupted")
|
|
387
|
+
}
|
|
388
|
+
if (reasonCode >= 128) {
|
|
389
|
+
throw MqttSessionException("ERR_UNSUBSCRIBE_FAILED", "Broker rejected tombstone", reasonCode = reasonCode, outcome = "rejected")
|
|
390
|
+
}
|
|
391
|
+
if (tombstones[filter] == intentRevision) {
|
|
392
|
+
tombstones.remove(filter)
|
|
393
|
+
confirmedSubscriptions.remove(filter)
|
|
394
|
+
}
|
|
395
|
+
Unit
|
|
396
|
+
}, serialExecutor)
|
|
397
|
+
.whenCompleteAsync({ _, error ->
|
|
398
|
+
pendingSubscriptionOperations = (pendingSubscriptionOperations - 1).coerceAtLeast(0)
|
|
399
|
+
if (error != null) subscriptionsStatus = "failed"
|
|
400
|
+
else if (pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
|
|
401
|
+
settleSubscriptionWaiters()
|
|
402
|
+
}, serialExecutor)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Serialize one filter's protocol operations so late ACKs cannot cross requests. */
|
|
407
|
+
private fun <T> enqueueFilterOperation(
|
|
408
|
+
filter: String,
|
|
409
|
+
serialExecutor: ExecutorService,
|
|
410
|
+
operation: () -> CompletableFuture<T>,
|
|
411
|
+
): CompletableFuture<T> {
|
|
412
|
+
val previous = subscriptionTails[filter] ?: CompletableFuture.completedFuture(Unit)
|
|
413
|
+
val next = previous
|
|
414
|
+
.handleAsync({ _, _ -> Unit }, serialExecutor)
|
|
415
|
+
.thenComposeAsync({ operation() }, serialExecutor)
|
|
416
|
+
val tail = next.handleAsync({ _, _ -> Unit }, serialExecutor)
|
|
417
|
+
subscriptionTails[filter] = tail
|
|
418
|
+
tail.whenCompleteAsync({ _, _ ->
|
|
419
|
+
if (subscriptionTails[filter] === tail) subscriptionTails.remove(filter)
|
|
420
|
+
}, serialExecutor)
|
|
421
|
+
return next
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private fun restoreSubscriptionBatch(
|
|
425
|
+
batch: List<Map.Entry<String, SubscriptionIntent>>,
|
|
426
|
+
currentTransport: MqttTransport,
|
|
427
|
+
serialExecutor: ExecutorService,
|
|
428
|
+
): CompletableFuture<Unit> {
|
|
429
|
+
pendingSubscriptionOperations += batch.size
|
|
430
|
+
val requests = batch.map { (filter, intent) ->
|
|
431
|
+
MqttSubscriptionRequest(filter, intent.qos, intent.noLocal, intent.retainAsPublished, intent.retainHandling)
|
|
432
|
+
}
|
|
433
|
+
return currentTransport.subscribeBatch(requests)
|
|
434
|
+
.thenApplyAsync({ results ->
|
|
435
|
+
if (state != "connected") throw MqttSessionException("ERR_CONNECTION_LOST", "Subscription restore was interrupted")
|
|
436
|
+
var failed = results.size != batch.size
|
|
437
|
+
results.take(batch.size).forEachIndexed { index, result ->
|
|
438
|
+
val (filter, intent) = batch[index]
|
|
439
|
+
val current = desiredSubscriptions[filter]
|
|
440
|
+
if (current == null || current.revision != intent.revision) return@forEachIndexed
|
|
441
|
+
if (result.grantedQos < 0) {
|
|
442
|
+
failed = true
|
|
443
|
+
} else {
|
|
444
|
+
current.grantedQos = result.grantedQos
|
|
445
|
+
confirmedSubscriptions[filter] = current.copy()
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (failed) throw MqttSessionException("ERR_SUBSCRIBE_FAILED", "Broker returned a partial or rejected SUBACK")
|
|
449
|
+
Unit
|
|
450
|
+
}, serialExecutor)
|
|
451
|
+
.whenCompleteAsync({ _, error ->
|
|
452
|
+
pendingSubscriptionOperations = (pendingSubscriptionOperations - batch.size).coerceAtLeast(0)
|
|
453
|
+
if (error != null) subscriptionsStatus = "failed"
|
|
454
|
+
settleSubscriptionWaiters()
|
|
455
|
+
}, serialExecutor)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private companion object {
|
|
459
|
+
const val RESTORE_MAX_FILTERS = 32
|
|
460
|
+
const val RESTORE_MAX_PACKET_BYTES = 16 * 1024
|
|
461
|
+
const val RESTORE_MAX_IN_FLIGHT = 4
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
fun awaitSubscriptions(generation: Long, desiredRevision: Long, serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
|
|
465
|
+
if (state != "connected") return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session is not connected"))
|
|
466
|
+
if (this.generation != generation) return failed(MqttSessionException("ERR_CONNECTION_LOST", "Connection generation changed"))
|
|
467
|
+
if (subscriptionsStatus == "failed") return failed(MqttSessionException("ERR_SUBSCRIBE_FAILED", "Subscription synchronization failed"))
|
|
468
|
+
if (subscriptionsRevision >= desiredRevision && subscriptionsStatus == "ready") return CompletableFuture.completedFuture(snapshot())
|
|
469
|
+
val waiter = CompletableFuture<Map<String, Any?>>().also {
|
|
470
|
+
subscriptionWaiters += SubscriptionWaiter(generation, desiredRevision, it)
|
|
471
|
+
}
|
|
472
|
+
return trackOperation(waiter, serialExecutor, "ready", null, generation)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private fun settleSubscriptionWaiters() {
|
|
476
|
+
val iterator = subscriptionWaiters.iterator()
|
|
477
|
+
while (iterator.hasNext()) {
|
|
478
|
+
val waiter = iterator.next()
|
|
479
|
+
when {
|
|
480
|
+
state != "connected" || generation != waiter.generation -> {
|
|
481
|
+
waiter.future.completeExceptionally(MqttSessionException("ERR_CONNECTION_LOST", "Connection generation changed"))
|
|
482
|
+
iterator.remove()
|
|
483
|
+
}
|
|
484
|
+
subscriptionsStatus == "failed" -> {
|
|
485
|
+
waiter.future.completeExceptionally(MqttSessionException("ERR_SUBSCRIBE_FAILED", "Subscription synchronization failed"))
|
|
486
|
+
iterator.remove()
|
|
487
|
+
}
|
|
488
|
+
subscriptionsStatus == "ready" && subscriptionsRevision >= waiter.desiredRevision -> {
|
|
489
|
+
waiter.future.complete(snapshot())
|
|
490
|
+
iterator.remove()
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
fun publish(topic: String, payload: ByteArray, qos: Int, retained: Boolean, properties: MqttPublishProperties = MqttPublishProperties(), serialExecutor: ExecutorService): CompletableFuture<Void> {
|
|
497
|
+
if (state != "connected") return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session is not connected"))
|
|
498
|
+
val currentTransport = transport
|
|
499
|
+
?: return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session transport is not connected"))
|
|
500
|
+
val operationGeneration = generation
|
|
501
|
+
val result: CompletableFuture<Void> = currentTransport.publish(topic, payload, qos, retained, properties)
|
|
502
|
+
.handle<Void> { _, error ->
|
|
503
|
+
if (error != null) {
|
|
504
|
+
throw mapProtocolOperationError(error, "publish", operationGeneration)
|
|
505
|
+
}
|
|
506
|
+
null
|
|
507
|
+
}
|
|
508
|
+
return trackOperation(result, serialExecutor, "publish", topic, operationGeneration)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
fun close(): Map<String, Any?> {
|
|
512
|
+
if (state != "closed") {
|
|
513
|
+
stopRequested = true
|
|
514
|
+
attemptId += 1
|
|
515
|
+
connecting?.cancel(true)
|
|
516
|
+
connecting = null
|
|
517
|
+
rejectReconnectWaiters(MqttSessionException("ERR_DISPOSED", "Session was closed"))
|
|
518
|
+
cancelPendingOperations(MqttSessionException("ERR_DISPOSED", "Session was closed"))
|
|
519
|
+
transport?.disconnect()
|
|
520
|
+
state = "closed"
|
|
521
|
+
revision += 1
|
|
522
|
+
settleSubscriptionWaiters()
|
|
523
|
+
}
|
|
524
|
+
return snapshot()
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
fun close(serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
|
|
528
|
+
if (state == "closed") return CompletableFuture.completedFuture(snapshot())
|
|
529
|
+
stopRequested = true
|
|
530
|
+
attemptId += 1
|
|
531
|
+
connecting?.cancel(true)
|
|
532
|
+
connecting = null
|
|
533
|
+
state = "disconnecting"
|
|
534
|
+
revision += 1
|
|
535
|
+
cancelPendingOperations(MqttSessionException("ERR_DISPOSED", "Session was closed"))
|
|
536
|
+
settleSubscriptionWaiters()
|
|
537
|
+
val disconnect = transport?.disconnect() ?: CompletableFuture.completedFuture(null)
|
|
538
|
+
return disconnect.thenApplyAsync({
|
|
539
|
+
state = "closed"
|
|
540
|
+
revision += 1
|
|
541
|
+
settleSubscriptionWaiters()
|
|
542
|
+
snapshot()
|
|
543
|
+
}, serialExecutor)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private fun <T> failed(error: Throwable): CompletableFuture<T> = CompletableFuture<T>().also { it.completeExceptionally(error) }
|
|
547
|
+
|
|
548
|
+
private fun mapProtocolOperationError(error: Throwable, operation: String, operationGeneration: Long): MqttSessionException {
|
|
549
|
+
val cause = findMqttSessionException(error) ?: (error.cause ?: error)
|
|
550
|
+
val protocolFailure = when (cause) {
|
|
551
|
+
is org.eclipse.paho.mqttv5.common.MqttException -> Triple("ERR_${operation.uppercase()}_FAILED", cause.reasonCode, null)
|
|
552
|
+
is org.eclipse.paho.client.mqttv3.MqttException -> Triple("ERR_${operation.uppercase()}_FAILED", cause.reasonCode, null)
|
|
553
|
+
else -> null
|
|
554
|
+
}
|
|
555
|
+
if (cause is MqttSessionException) return cause
|
|
556
|
+
return MqttSessionException(
|
|
557
|
+
protocolFailure?.first ?: when (operation) {
|
|
558
|
+
"subscribe" -> "ERR_SUBSCRIBE_FAILED"
|
|
559
|
+
"unsubscribe" -> "ERR_UNSUBSCRIBE_FAILED"
|
|
560
|
+
else -> "ERR_PUBLISH_FAILED"
|
|
561
|
+
},
|
|
562
|
+
cause.message ?: "MQTT $operation failed",
|
|
563
|
+
cause = cause,
|
|
564
|
+
operation = operation,
|
|
565
|
+
generation = operationGeneration,
|
|
566
|
+
reasonCode = protocolFailure?.second,
|
|
567
|
+
reasonString = protocolFailure?.third,
|
|
568
|
+
outcome = if (protocolFailure != null) "rejected" else "unknown",
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
@Synchronized
|
|
573
|
+
private fun <T> trackOperation(
|
|
574
|
+
operation: CompletableFuture<T>,
|
|
575
|
+
serialExecutor: ExecutorService,
|
|
576
|
+
operationType: String,
|
|
577
|
+
topic: String?,
|
|
578
|
+
operationGeneration: Long,
|
|
579
|
+
): CompletableFuture<T> {
|
|
580
|
+
val operationId = ++nextOperationId
|
|
581
|
+
val entry = OperationLedgerEntry(operationId, operationGeneration, operationType, topic)
|
|
582
|
+
operationLedger[operationId] = entry
|
|
583
|
+
pendingOperationFutures += operation
|
|
584
|
+
operation.whenCompleteAsync({ _, _ ->
|
|
585
|
+
synchronized(this) {
|
|
586
|
+
// Completion, timeout, disconnect and dispose all converge here. The
|
|
587
|
+
// settled bit makes the ledger's removal explicitly idempotent even
|
|
588
|
+
// when a source future invokes its callback more than once.
|
|
589
|
+
if (!entry.settled) {
|
|
590
|
+
entry.settled = true
|
|
591
|
+
operationLedger.remove(operationId)
|
|
592
|
+
}
|
|
593
|
+
pendingOperationFutures.remove(operation)
|
|
594
|
+
}
|
|
595
|
+
}, serialExecutor)
|
|
596
|
+
return operation
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
@Synchronized
|
|
600
|
+
private fun cancelPendingOperations(error: Throwable) {
|
|
601
|
+
pendingOperationFutures.toList().forEach { it.completeExceptionally(error) }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
fun sameOptions(other: ParsedMqttOptions): Boolean = options == other
|
|
605
|
+
|
|
606
|
+
fun connectTimeoutMs(): Long = options.connectTimeoutMs
|
|
607
|
+
|
|
608
|
+
fun operationTimeoutMs(): Long = options.operationTimeoutMs
|
|
609
|
+
|
|
610
|
+
fun disconnectTimeoutMs(): Long = options.disconnectTimeoutMs
|
|
611
|
+
|
|
612
|
+
fun reconnectEnabled(): Boolean = options.reconnectEnabled
|
|
613
|
+
|
|
614
|
+
@Synchronized
|
|
615
|
+
fun pendingOperations(): Int = pendingOperationFutures.size
|
|
616
|
+
|
|
617
|
+
@Synchronized
|
|
618
|
+
fun operationLedgerSnapshot(): List<Map<String, Any?>> = operationLedger.values.map { entry ->
|
|
619
|
+
mapOf(
|
|
620
|
+
"operationId" to entry.operationId,
|
|
621
|
+
"generation" to entry.generation,
|
|
622
|
+
"operation" to entry.operation,
|
|
623
|
+
"topic" to entry.topic,
|
|
624
|
+
"settled" to entry.settled,
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fun reconnectAttemptsCount(): Long = reconnectAttempts
|
|
629
|
+
|
|
630
|
+
fun reconnectDelayMs(): Long {
|
|
631
|
+
reconnectAttempts += 1
|
|
632
|
+
val exponent = (reconnectAttempts - 1).coerceAtMost(30).toInt()
|
|
633
|
+
val base = (options.reconnectInitialDelayMs * (1L shl exponent)).coerceAtMost(options.reconnectMaxDelayMs)
|
|
634
|
+
val spread = (base.toDouble() * options.reconnectJitterRatio).toLong()
|
|
635
|
+
return if (spread == 0L) base else (base + ThreadLocalRandom.current().nextLong(-spread, spread + 1)).coerceAtLeast(1L)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
fun stopReconnecting() {
|
|
639
|
+
if (state == "reconnecting") {
|
|
640
|
+
state = "disconnected"
|
|
641
|
+
revision += 1
|
|
642
|
+
settleSubscriptionWaiters()
|
|
643
|
+
}
|
|
644
|
+
rejectReconnectWaiters(MqttSessionException("ERR_CONNECTION_LOST", "Automatic reconnect is disabled"))
|
|
645
|
+
reconnectAttempts = 0
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
fun cancelReconnectWaiter(waiter: CompletableFuture<Map<String, Any?>>, error: Throwable) {
|
|
649
|
+
if (reconnectWaiters.remove(waiter)) waiter.completeExceptionally(error)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
fun forceDisconnect() {
|
|
653
|
+
if (state != "closed" && state != "disconnected") {
|
|
654
|
+
state = "disconnected"
|
|
655
|
+
revision += 1
|
|
656
|
+
settleSubscriptionWaiters()
|
|
657
|
+
}
|
|
658
|
+
stopRequested = true
|
|
659
|
+
attemptId += 1
|
|
660
|
+
connecting?.cancel(true)
|
|
661
|
+
connecting = null
|
|
662
|
+
rejectReconnectWaiters(MqttSessionException("ERR_CONNECTION_LOST", "Connection was stopped"))
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
fun forceClose() {
|
|
666
|
+
stopRequested = true
|
|
667
|
+
attemptId += 1
|
|
668
|
+
connecting?.cancel(true)
|
|
669
|
+
connecting = null
|
|
670
|
+
rejectReconnectWaiters(MqttSessionException("ERR_DISPOSED", "Session was closed"))
|
|
671
|
+
if (state != "closed") {
|
|
672
|
+
state = "closed"
|
|
673
|
+
revision += 1
|
|
674
|
+
settleSubscriptionWaiters()
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private fun rejectReconnectWaiters(error: Throwable) {
|
|
679
|
+
reconnectWaiters.forEach { it.completeExceptionally(error) }
|
|
680
|
+
reconnectWaiters.clear()
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
fun isReconnecting(): Boolean = state == "reconnecting"
|
|
684
|
+
|
|
685
|
+
private var reconnectAttempts = 0L
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
internal class MqttSessionRegistry {
|
|
689
|
+
constructor(
|
|
690
|
+
onMessage: (String, Long, MqttMessageData) -> Unit = { _, _, _ -> },
|
|
691
|
+
transportFactory: ((ParsedMqttOptions, (MqttMessageData) -> Unit) -> MqttTransport)? = null,
|
|
692
|
+
onTransportLost: (String, Long, MqttDisconnect?) -> Unit = { _, _, _ -> },
|
|
693
|
+
onSessionSnapshot: (Map<String, Any?>) -> Unit = {},
|
|
694
|
+
scheduler: MqttScheduler = ExecutorMqttScheduler(),
|
|
695
|
+
) {
|
|
696
|
+
this.onMessage = onMessage
|
|
697
|
+
this.transportFactory = transportFactory
|
|
698
|
+
this.onTransportLost = onTransportLost
|
|
699
|
+
this.onSessionSnapshot = onSessionSnapshot
|
|
700
|
+
this.scheduler = scheduler
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private val onMessage: (String, Long, MqttMessageData) -> Unit
|
|
704
|
+
private val transportFactory: ((ParsedMqttOptions, (MqttMessageData) -> Unit) -> MqttTransport)?
|
|
705
|
+
private val onTransportLost: (String, Long, MqttDisconnect?) -> Unit
|
|
706
|
+
private val onSessionSnapshot: (Map<String, Any?>) -> Unit
|
|
707
|
+
private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
|
708
|
+
Thread(runnable, "react-native-mqtt-registry").apply { isDaemon = true }
|
|
709
|
+
}
|
|
710
|
+
private val scheduler: MqttScheduler
|
|
711
|
+
private val sessions = ConcurrentHashMap<String, MqttSession>()
|
|
712
|
+
private val reconnectTasks = ConcurrentHashMap<String, MqttTimer>()
|
|
713
|
+
private val invalidated = AtomicBoolean(false)
|
|
714
|
+
|
|
715
|
+
fun open(instanceId: String, options: ParsedMqttOptions): CompletableFuture<Map<String, Any?>> {
|
|
716
|
+
val result = CompletableFuture<Map<String, Any?>>()
|
|
717
|
+
try {
|
|
718
|
+
executor.execute {
|
|
719
|
+
if (invalidated.get()) {
|
|
720
|
+
result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
|
|
721
|
+
} else {
|
|
722
|
+
val existing = sessions[instanceId]
|
|
723
|
+
if (existing != null && !existing.sameOptions(options)) {
|
|
724
|
+
result.completeExceptionally(MqttSessionException("ERR_OPERATION_IN_PROGRESS", "Session options cannot change"))
|
|
725
|
+
} else {
|
|
726
|
+
val session = existing ?: MqttSession(instanceId, options, onMessage = { generation, message ->
|
|
727
|
+
try {
|
|
728
|
+
executor.execute { if (!invalidated.get() && sessions.containsKey(instanceId)) onMessage(instanceId, generation, message) }
|
|
729
|
+
} catch (_: RejectedExecutionException) {
|
|
730
|
+
// Invalidation owns teardown; late SDK callbacks are ignored.
|
|
731
|
+
}
|
|
732
|
+
}, transportFactory = transportFactory, onTransportLost = { generation, reason ->
|
|
733
|
+
onTransportLost(instanceId, generation, reason)
|
|
734
|
+
try {
|
|
735
|
+
executor.execute { scheduleReconnect(instanceId) }
|
|
736
|
+
} catch (_: RejectedExecutionException) {
|
|
737
|
+
// Registry teardown owns the terminal state.
|
|
738
|
+
}
|
|
739
|
+
}).also { sessions[instanceId] = it }
|
|
740
|
+
result.complete(session.snapshot())
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} catch (error: RejectedExecutionException) {
|
|
745
|
+
result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
|
|
746
|
+
}
|
|
747
|
+
return result
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
fun snapshot(instanceId: String): CompletableFuture<Map<String, Any?>> = submit {
|
|
751
|
+
sessions[instanceId]?.snapshot() ?: closedSnapshot(instanceId)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
fun pendingOperations(instanceId: String): Int = sessions[instanceId]?.pendingOperations() ?: 0
|
|
755
|
+
|
|
756
|
+
fun operationLedger(instanceId: String): List<Map<String, Any?>> = sessions[instanceId]?.operationLedgerSnapshot() ?: emptyList()
|
|
757
|
+
|
|
758
|
+
fun reconnectAttempts(instanceId: String): Long = sessions[instanceId]?.reconnectAttemptsCount() ?: 0
|
|
759
|
+
|
|
760
|
+
fun close(instanceId: String): CompletableFuture<Map<String, Any?>> = submitFuture {
|
|
761
|
+
val session = sessions[instanceId] ?: return@submitFuture CompletableFuture.completedFuture(closedSnapshot(instanceId))
|
|
762
|
+
reconnectTasks.remove(instanceId)?.cancel()
|
|
763
|
+
val result = withDeadline(
|
|
764
|
+
session.close(executor),
|
|
765
|
+
session.disconnectTimeoutMs(),
|
|
766
|
+
"ERR_OPERATION_TIMEOUT",
|
|
767
|
+
"Close timed out",
|
|
768
|
+
onTimeout = { session.forceClose() },
|
|
769
|
+
)
|
|
770
|
+
result.whenComplete { _, _ ->
|
|
771
|
+
sessions.remove(instanceId, session)
|
|
772
|
+
reconnectTasks.remove(instanceId)?.cancel()
|
|
773
|
+
}
|
|
774
|
+
result
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
fun connect(instanceId: String): CompletableFuture<Map<String, Any?>> {
|
|
778
|
+
val result = CompletableFuture<Map<String, Any?>>()
|
|
779
|
+
try {
|
|
780
|
+
executor.execute {
|
|
781
|
+
if (invalidated.get()) {
|
|
782
|
+
result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
|
|
783
|
+
return@execute
|
|
784
|
+
}
|
|
785
|
+
val session = sessions[instanceId]
|
|
786
|
+
if (session == null) {
|
|
787
|
+
result.completeExceptionally(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
|
|
788
|
+
return@execute
|
|
789
|
+
}
|
|
790
|
+
if (!session.isReconnecting()) reconnectTasks.remove(instanceId)?.cancel()
|
|
791
|
+
var timeout: MqttTimer? = null
|
|
792
|
+
try {
|
|
793
|
+
val connection = session.connect(executor)
|
|
794
|
+
timeout = scheduler.schedule(session.connectTimeoutMs()) {
|
|
795
|
+
executor.execute {
|
|
796
|
+
if (!result.isDone) {
|
|
797
|
+
val timeoutError = MqttSessionException("ERR_CONNECT_TIMEOUT", "Connection timed out")
|
|
798
|
+
session.timeoutConnect()
|
|
799
|
+
session.cancelReconnectWaiter(connection, timeoutError)
|
|
800
|
+
result.completeExceptionally(timeoutError)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
connection.whenComplete { value, error ->
|
|
805
|
+
executor.execute {
|
|
806
|
+
timeout?.cancel()
|
|
807
|
+
if (error == null) {
|
|
808
|
+
result.complete(value)
|
|
809
|
+
} else {
|
|
810
|
+
onSessionSnapshot(session.snapshot())
|
|
811
|
+
result.completeExceptionally(error)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
} catch (error: Throwable) {
|
|
816
|
+
timeout?.cancel()
|
|
817
|
+
onSessionSnapshot(session.snapshot())
|
|
818
|
+
result.completeExceptionally(error)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
} catch (error: RejectedExecutionException) {
|
|
822
|
+
result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
|
|
823
|
+
}
|
|
824
|
+
return result
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
fun disconnect(instanceId: String): CompletableFuture<Map<String, Any?>> = submitFuture {
|
|
828
|
+
reconnectTasks.remove(instanceId)?.cancel()
|
|
829
|
+
val session = sessions[instanceId]
|
|
830
|
+
if (session == null) CompletableFuture.completedFuture(closedSnapshot(instanceId))
|
|
831
|
+
else withDeadline(
|
|
832
|
+
session.disconnect(executor),
|
|
833
|
+
session.disconnectTimeoutMs(),
|
|
834
|
+
"ERR_OPERATION_TIMEOUT",
|
|
835
|
+
"Disconnect timed out",
|
|
836
|
+
onTimeout = { session.forceDisconnect() },
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
fun setSubscription(instanceId: String, filter: String, qos: Int, noLocal: Boolean, retainAsPublished: Boolean, retainHandling: Int, intentRevision: Long): CompletableFuture<Map<String, Any?>> =
|
|
841
|
+
submitFuture {
|
|
842
|
+
val session = sessions[instanceId]
|
|
843
|
+
if (session == null) failedFuture(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
|
|
844
|
+
else withDeadline(
|
|
845
|
+
session.setSubscription(filter, qos, noLocal, retainAsPublished, retainHandling, intentRevision, executor),
|
|
846
|
+
session.operationTimeoutMs(),
|
|
847
|
+
"ERR_OPERATION_TIMEOUT",
|
|
848
|
+
"Subscription operation timed out",
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
fun removeSubscription(instanceId: String, filter: String, intentRevision: Long): CompletableFuture<Map<String, Any?>> =
|
|
853
|
+
submitFuture {
|
|
854
|
+
val session = sessions[instanceId]
|
|
855
|
+
if (session == null) failedFuture(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
|
|
856
|
+
else withDeadline(
|
|
857
|
+
session.removeSubscription(filter, intentRevision, executor),
|
|
858
|
+
session.operationTimeoutMs(),
|
|
859
|
+
"ERR_OPERATION_TIMEOUT",
|
|
860
|
+
"Unsubscription operation timed out",
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
fun awaitSubscriptions(instanceId: String, generation: Long, desiredRevision: Long): CompletableFuture<Map<String, Any?>> =
|
|
865
|
+
submitFuture {
|
|
866
|
+
val session = sessions[instanceId]
|
|
867
|
+
if (session == null) failedFuture(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
|
|
868
|
+
else withDeadline(
|
|
869
|
+
session.awaitSubscriptions(generation, desiredRevision, executor),
|
|
870
|
+
session.operationTimeoutMs(),
|
|
871
|
+
"ERR_OPERATION_TIMEOUT",
|
|
872
|
+
"Subscription readiness timed out",
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
fun publish(instanceId: String, topic: String, payload: ByteArray, qos: Int, retained: Boolean, properties: MqttPublishProperties = MqttPublishProperties()): CompletableFuture<Void> {
|
|
877
|
+
val result = CompletableFuture<Void>()
|
|
878
|
+
try {
|
|
879
|
+
executor.execute {
|
|
880
|
+
if (invalidated.get()) result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
|
|
881
|
+
else {
|
|
882
|
+
val session = sessions[instanceId]
|
|
883
|
+
if (session == null) result.completeExceptionally(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
|
|
884
|
+
else withDeadline(
|
|
885
|
+
session.publish(topic, payload, qos, retained, properties, executor),
|
|
886
|
+
session.operationTimeoutMs(),
|
|
887
|
+
"ERR_OPERATION_TIMEOUT",
|
|
888
|
+
"Publish operation timed out",
|
|
889
|
+
).whenComplete { value, error ->
|
|
890
|
+
executor.execute { if (error == null) result.complete(value) else result.completeExceptionally(error) }
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
} catch (error: RejectedExecutionException) {
|
|
895
|
+
result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
|
|
896
|
+
}
|
|
897
|
+
return result
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
fun invalidate() {
|
|
901
|
+
if (!invalidated.compareAndSet(false, true)) return
|
|
902
|
+
executor.execute {
|
|
903
|
+
reconnectTasks.values.forEach { it.cancel() }
|
|
904
|
+
reconnectTasks.clear()
|
|
905
|
+
sessions.values.forEach { it.close() }
|
|
906
|
+
sessions.clear()
|
|
907
|
+
executor.shutdown()
|
|
908
|
+
scheduler.shutdown()
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private fun scheduleReconnect(instanceId: String) {
|
|
913
|
+
val session = sessions[instanceId] ?: return
|
|
914
|
+
if (!session.reconnectEnabled()) {
|
|
915
|
+
session.stopReconnecting()
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
val existing = reconnectTasks[instanceId]
|
|
919
|
+
if (existing != null && !existing.isDone()) return
|
|
920
|
+
val delay = session.reconnectDelayMs()
|
|
921
|
+
reconnectTasks[instanceId] = scheduler.schedule(delay) {
|
|
922
|
+
try {
|
|
923
|
+
executor.execute {
|
|
924
|
+
reconnectTasks.remove(instanceId)
|
|
925
|
+
val current = sessions[instanceId] ?: return@execute
|
|
926
|
+
current.connect(executor, forceAttempt = true).whenComplete { _, error ->
|
|
927
|
+
executor.execute {
|
|
928
|
+
if (error != null && sessions[instanceId] === current && current.reconnectEnabled()) scheduleReconnect(instanceId)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
} catch (_: RejectedExecutionException) {
|
|
933
|
+
// Registry teardown owns the terminal state.
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private fun submit(block: () -> Map<String, Any?>): CompletableFuture<Map<String, Any?>> {
|
|
939
|
+
val result = CompletableFuture<Map<String, Any?>>()
|
|
940
|
+
try {
|
|
941
|
+
executor.execute {
|
|
942
|
+
if (invalidated.get()) result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
|
|
943
|
+
else runCatching(block).onSuccess(result::complete).onFailure(result::completeExceptionally)
|
|
944
|
+
}
|
|
945
|
+
} catch (error: RejectedExecutionException) {
|
|
946
|
+
result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
|
|
947
|
+
}
|
|
948
|
+
return result
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private fun submitFuture(block: () -> CompletableFuture<Map<String, Any?>>): CompletableFuture<Map<String, Any?>> {
|
|
952
|
+
val result = CompletableFuture<Map<String, Any?>>()
|
|
953
|
+
try {
|
|
954
|
+
executor.execute {
|
|
955
|
+
if (invalidated.get()) result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
|
|
956
|
+
else try { block().whenComplete { value, error -> if (error == null) result.complete(value) else result.completeExceptionally(error) } } catch (error: Throwable) { result.completeExceptionally(error) }
|
|
957
|
+
}
|
|
958
|
+
} catch (error: RejectedExecutionException) { result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error)) }
|
|
959
|
+
return result
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private fun <T> withDeadline(
|
|
963
|
+
source: CompletableFuture<T>,
|
|
964
|
+
timeoutMs: Long,
|
|
965
|
+
errorCode: String,
|
|
966
|
+
message: String,
|
|
967
|
+
onTimeout: () -> Unit = {},
|
|
968
|
+
): CompletableFuture<T> {
|
|
969
|
+
val result = CompletableFuture<T>()
|
|
970
|
+
val timeout = scheduler.schedule(timeoutMs) {
|
|
971
|
+
if (result.completeExceptionally(MqttSessionException(errorCode, message))) {
|
|
972
|
+
onTimeout()
|
|
973
|
+
source.cancel(true)
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
source.whenComplete { value, error ->
|
|
977
|
+
timeout.cancel()
|
|
978
|
+
if (error == null) result.complete(value) else result.completeExceptionally(error)
|
|
979
|
+
}
|
|
980
|
+
return result
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private fun closedSnapshot(instanceId: String): Map<String, Any?> = mapOf(
|
|
984
|
+
"instanceId" to instanceId,
|
|
985
|
+
"revision" to 0L,
|
|
986
|
+
"generation" to 0L,
|
|
987
|
+
"state" to "closed",
|
|
988
|
+
"lastConnAck" to null,
|
|
989
|
+
"subscriptionsRevision" to 0L,
|
|
990
|
+
"subscriptionsStatus" to "ready",
|
|
991
|
+
"deliveryStatus" to "closed",
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
private fun <T> failedFuture(error: Throwable): CompletableFuture<T> = CompletableFuture<T>().also { it.completeExceptionally(error) }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
internal class MqttSessionException(
|
|
998
|
+
val errorCode: String,
|
|
999
|
+
message: String,
|
|
1000
|
+
cause: Throwable? = null,
|
|
1001
|
+
val operation: String? = null,
|
|
1002
|
+
val generation: Long? = null,
|
|
1003
|
+
val reasonCode: Int? = null,
|
|
1004
|
+
val reasonString: String? = null,
|
|
1005
|
+
val outcome: String? = null,
|
|
1006
|
+
) : RuntimeException(message, cause)
|