unified-ble-manager 4.0.23 → 4.0.24

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 (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/MIGRATION_4.0.md +1 -1
  3. package/README.md +1 -1
  4. package/RELEASE.md +11 -3
  5. package/SBOM.cdx.json +4 -4
  6. package/THIRD_PARTY_LICENSES.json +1 -1
  7. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcher.kt +5 -3
  8. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt +196 -60
  9. package/android/src/test/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcherLifecycleTest.kt +202 -0
  10. package/docs/ELECTRON.md +1 -1
  11. package/docs/EXPO_PLUGIN.md +1 -1
  12. package/docs/GAPS.4.0.md +1 -1
  13. package/docs/GETTING_STARTED.md +3 -3
  14. package/docs/NODE.md +1 -1
  15. package/docs/WEB.md +1 -1
  16. package/docs/generated/PLATFORM_SUPPORT.md +1 -1
  17. package/lib/commonjs/implementation-version.js +1 -1
  18. package/lib/module/implementation-version.js +1 -1
  19. package/lib/typescript/commonjs/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  20. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  21. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  22. package/lib/typescript/commonjs/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  23. package/lib/typescript/commonjs/src/backends/winrt/winrt-provider.d.ts +1 -1
  24. package/lib/typescript/commonjs/src/implementation-version.d.ts +1 -1
  25. package/lib/typescript/module/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  26. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  27. package/lib/typescript/module/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  28. package/lib/typescript/module/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  29. package/lib/typescript/module/src/backends/winrt/winrt-provider.d.ts +1 -1
  30. package/lib/typescript/module/src/implementation-version.d.ts +1 -1
  31. package/native/PREBUILDS.json +5 -5
  32. package/native/electron/corebluetooth/prebuilds/darwin-arm64/unified_ble_corebluetooth.node +0 -0
  33. package/native/electron/corebluetooth/prebuilds/darwin-x64/unified_ble_corebluetooth.node +0 -0
  34. package/native/electron/winrt/prebuilds/win32-arm64/unified_ble_winrt.node +0 -0
  35. package/native/electron/winrt/prebuilds/win32-x64/unified_ble_winrt.node +0 -0
  36. package/package.json +1 -1
  37. package/src/implementation-version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ All notable changes to `unified-ble-manager` are documented here.
6
6
 
7
7
  No changes yet.
8
8
 
9
+ ## [4.0.24] - 2026-09-05
10
+
11
+ ### Fixes
12
+
13
+ - Arbitrate an Android characteristic-write callback failure against an
14
+ already-in-flight disconnect callback. A provisional write status such as
15
+ 133 is retained for a bounded 250 ms evidence window, allowing the
16
+ authoritative connection-state callback to report typed `connection.lost`
17
+ without relabeling an isolated status 133 as link loss.
18
+ - Preserve the Android GATT operation and status for synchronous and
19
+ asynchronous characteristic-write failures that are not proven link loss.
20
+ - Make exact and UUID-keyed pending-write cleanup identity-safe so stale
21
+ teardown and late callbacks cannot settle a replacement write or remove its
22
+ payload.
23
+
9
24
  ## [4.0.23] - 2026-09-05
10
25
 
11
26
  ### Fixes
package/MIGRATION_4.0.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Migrating from react-native-ble-plx
4
4
 
5
- This source targets `4.0.23`. The `4.0.0` release is a new package and a new contract. It is **not a source-compatible rename**. There is no `new BleManager()` facade, no Base64 characteristic values, and no public transaction IDs.
5
+ This source targets `4.0.24`. The `4.0.0` release is a new package and a new contract. It is **not a source-compatible rename**. There is no `new BleManager()` facade, no Base64 characteristic values, and no public transaction IDs.
6
6
 
7
7
  This page is for a React Native app that already uses `react-native-ble-plx`. Web, Electron, Node, and Tauri are new hosts — use those pages after you understand the RN rewrite.
8
8
 
package/README.md CHANGED
@@ -18,7 +18,7 @@ root import does not pick a radio. Package SemVer and backend support labels are
18
18
  independent: each radio backend keeps its evidence-derived label. See
19
19
  [`docs/PLATFORMS.md`](docs/PLATFORMS.md).
20
20
 
21
- This source tree is versioned `4.0.23`. Install the exact version shown in the npm
21
+ This source tree is versioned `4.0.24`. Install the exact version shown in the npm
22
22
  registry. During release preparation, the version in `package.json` can be ahead
23
23
  of npm until the matching tag-driven workflow publishes it; the registry and
24
24
  GitHub release remain authoritative.
package/RELEASE.md CHANGED
@@ -82,8 +82,8 @@ Active `4.0.0-rc.*` release-train candidates publish to npm `latest` so a bare `
82
82
  On release day, set `release_candidate` to the exact candidate required by the
83
83
  release plan. RC2, RC3, RC4, `4.0.0-rc.4.1`, and RC5 are already immutable
84
84
  once tagged. Stable `4.0.0` through `4.0.20` are immutable. The unpublished
85
- `v4.0.21` tag is also immutable after its cancelled workflow. `4.0.22` is immutable published history. `4.0.23` is the
86
- current train head.
85
+ `v4.0.21` tag is also immutable after its cancelled workflow. `4.0.22` and
86
+ `4.0.23` are immutable published history. `4.0.24` is the current train head.
87
87
 
88
88
  ```sh
89
89
  release_candidate=4.0.0-rc.N
@@ -111,6 +111,14 @@ The first stable tag `v4.0.0` is immutable published history. Do not recreate or
111
111
  git tag -a v4.0.0 -m "v4.0.0"
112
112
  ```
113
113
 
114
+ ## Releasing 4.0.24
115
+
116
+ Release `v4.0.24` only from the exact current `main` commit after its canonical
117
+ CI succeeds. Verify `package.json` is `4.0.24`, the worktree is clean, and
118
+ release-note extraction finds `## [4.0.24]`. Push a new annotated `v4.0.24`
119
+ tag; never move an earlier immutable tag. Follow the required local validation,
120
+ publish workflow, and registry verification below.
121
+
114
122
  ## Releasing 4.0.23
115
123
 
116
124
  Release `v4.0.23` only from the exact current `main` commit after its canonical
@@ -483,7 +491,7 @@ a green publish job and a package a consumer can actually install are not the
483
491
  same claim.
484
492
 
485
493
  ```sh
486
- version=4.0.23
494
+ version=4.0.24
487
495
 
488
496
  npm view "unified-ble-manager@$version" version
489
497
  npm view unified-ble-manager dist-tags --json
package/SBOM.cdx.json CHANGED
@@ -6,15 +6,15 @@
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "library",
9
- "bom-ref": "pkg:npm/unified-ble-manager@4.0.23",
9
+ "bom-ref": "pkg:npm/unified-ble-manager@4.0.24",
10
10
  "name": "unified-ble-manager",
11
- "version": "4.0.23",
11
+ "version": "4.0.24",
12
12
  "licenses": [
13
13
  {
14
14
  "expression": "Apache-2.0"
15
15
  }
16
16
  ],
17
- "purl": "pkg:npm/unified-ble-manager@4.0.23"
17
+ "purl": "pkg:npm/unified-ble-manager@4.0.24"
18
18
  },
19
19
  "properties": [
20
20
  {
@@ -537,7 +537,7 @@
537
537
  "dependsOn": []
538
538
  },
539
539
  {
540
- "ref": "pkg:npm/unified-ble-manager@4.0.23",
540
+ "ref": "pkg:npm/unified-ble-manager@4.0.24",
541
541
  "dependsOn": [
542
542
  "pkg:npm/%40babel/runtime@7.29.7",
543
543
  "pkg:npm/node-addon-api@8.9.0",
@@ -4,7 +4,7 @@
4
4
  "schemaVersion": "1.0.0",
5
5
  "package": {
6
6
  "name": "unified-ble-manager",
7
- "version": "4.0.23"
7
+ "version": "4.0.24"
8
8
  },
9
9
  "source": {
10
10
  "method": "pnpm-lock production graph with installed-manifest license audit",
@@ -409,7 +409,9 @@ class UnifiedBleProtocolAndroidDispatcher(
409
409
  ) { result ->
410
410
  result.fold(
411
411
  onSuccess = { emitSuccess(command, "write") },
412
- onFailure = { error -> emitFailure(command, "writeFailed", error.message ?: "Android GATT write failed") }
412
+ onFailure = { error ->
413
+ emitGattOperationFailure(command, "writeFailed", error, "Android GATT write failed")
414
+ }
413
415
  )
414
416
  }
415
417
  radioOperationIds[operationKey(command)] = radioOperationId
@@ -880,8 +882,8 @@ class UnifiedBleProtocolAndroidDispatcher(
880
882
  androidGattStatus: Int? = null
881
883
  ) {
882
884
  // Claim before constructing or emitting the terminal. Link loss can race a
883
- // native CCCD callback; a check followed by a later claim lets both paths
884
- // publish a result for the same operation.
885
+ // native GATT operation callback; a check followed by a later claim lets
886
+ // both paths publish a result for the same operation.
885
887
  if (!claimExactPendingCommand(pendingCommands, operationKey(command), command)) return
886
888
  val errorFields = mutableMapOf<Int, ProtocolWireValue>(
887
889
  1 to ProtocolWireValue.StringValue(code),
@@ -49,6 +49,61 @@ internal data class OwnedRadioTeardownResult(
49
49
  get() = failures.isEmpty()
50
50
  }
51
51
 
52
+ /** Identity-bearing owner for the legacy UUID-keyed characteristic-write path. */
53
+ internal class AndroidPendingWriteOwner<Key>(
54
+ val deviceKeyUpper: String,
55
+ val attribute: Key,
56
+ val value: ByteArray,
57
+ val callback: (Result<ByteArray?>) -> Unit
58
+ )
59
+
60
+ /**
61
+ * Keeps replacement writes isolated when their legacy string key is reused.
62
+ * Teardown must remove the exact owner it observed, never a newer operation.
63
+ */
64
+ internal class AndroidPendingWriteRegistry<Key> {
65
+ private val entries = ConcurrentHashMap<String, AndroidPendingWriteOwner<Key>>()
66
+
67
+ fun putIfAbsent(key: String, owner: AndroidPendingWriteOwner<Key>): Boolean =
68
+ entries.putIfAbsent(key, owner) == null
69
+
70
+ fun get(key: String): AndroidPendingWriteOwner<Key>? = entries[key]
71
+
72
+ fun remove(key: String, owner: AndroidPendingWriteOwner<Key>): Boolean =
73
+ entries.remove(key, owner)
74
+
75
+ /** Completes only the currently registered attribute, rejecting stale callbacks. */
76
+ fun complete(
77
+ key: String,
78
+ attribute: Key,
79
+ result: Result<ByteArray?>
80
+ ): Boolean {
81
+ val owner = entries[key] ?: return false
82
+ if (owner.attribute !== attribute) return false
83
+ if (!entries.remove(key, owner)) return false
84
+ owner.callback(result)
85
+ return true
86
+ }
87
+
88
+ fun remove(key: String): AndroidPendingWriteOwner<Key>? = entries.remove(key)
89
+
90
+ fun entriesForDevice(deviceKeyUpper: String): List<Pair<String, AndroidPendingWriteOwner<Key>>> =
91
+ entries.entries
92
+ .filter { entry -> entry.value.deviceKeyUpper == deviceKeyUpper }
93
+ .map { entry -> entry.key to entry.value }
94
+
95
+ fun clear() {
96
+ entries.clear()
97
+ }
98
+ }
99
+
100
+ /** Removes an exact-write payload only when it is still the observed payload. */
101
+ internal fun <Key> removeAndroidWritePayloadIfSame(
102
+ values: ConcurrentHashMap<Key, ByteArray>,
103
+ key: Key,
104
+ expected: ByteArray?
105
+ ): Boolean = expected != null && values.remove(key, expected)
106
+
52
107
  /**
53
108
  * Tracks exact native notification registrations by GATT generation.
54
109
  *
@@ -112,7 +167,7 @@ internal class OwnedAndroidSubscriptionOwnership<K> {
112
167
  /**
113
168
  * Preserves the Android GATT callback status when an asynchronous operation
114
169
  * terminates. Status 19 means that the peer terminated the link; keeping that
115
- * distinction typed prevents a CCCD race from being flattened to a generic
170
+ * distinction typed prevents a GATT operation race from being flattened to a generic
116
171
  * platform failure before the later connection-state callback arrives.
117
172
  */
118
173
  internal class AndroidGattOperationFailure(
@@ -135,35 +190,49 @@ internal fun classifyAndroidNotificationRegistrationFailure(
135
190
  ): AndroidGattOperationFailure = AndroidGattOperationFailure(operation, null, isLinkLoss = true)
136
191
 
137
192
  /**
138
- * Android may deliver an ordinary CCCD callback immediately before the
139
- * authoritative status-19 connection callback for the same GATT generation.
140
- * Keep only that provisional result pending long enough for lifecycle evidence
141
- * already in flight to claim it. Descriptor writes and already-typed link loss
142
- * are never delayed.
193
+ * Android may deliver an ordinary CCCD or characteristic-write callback
194
+ * immediately before authoritative connection-loss evidence for the same GATT
195
+ * generation. Keep only that provisional result pending long enough for the
196
+ * lifecycle evidence already in flight to claim it. Descriptor writes and
197
+ * already-typed link loss are never delayed.
143
198
  */
144
199
  internal fun shouldAwaitAndroidCccdDisconnectEvidence(failure: Throwable): Boolean =
145
200
  failure is AndroidGattOperationFailure &&
146
201
  failure.operation == "cccd-write" &&
147
202
  !failure.isLinkLoss
148
203
 
149
- private const val ANDROID_CCCD_DISCONNECT_EVIDENCE_GRACE_MS = 250L
204
+ internal fun shouldAwaitAndroidWriteDisconnectEvidence(failure: Throwable): Boolean =
205
+ failure is AndroidGattOperationFailure &&
206
+ failure.operation == "characteristic-write" &&
207
+ !failure.isLinkLoss
208
+
209
+ private const val ANDROID_GATT_DISCONNECT_EVIDENCE_GRACE_MS = 250L
150
210
 
151
- internal class AndroidCccdTerminalArbiter<Key>(
211
+ /**
212
+ * Holds an asynchronous GATT callback failure briefly so a connection-state
213
+ * callback already in flight can provide the authoritative terminal. The
214
+ * first failure is retained and each key can be claimed only once.
215
+ */
216
+ internal class AndroidGattTerminalArbiter<Key>(
152
217
  private val schedule: (delayMs: Long, action: () -> Unit) -> Boolean,
153
218
  private val onFallback: (key: Key, failure: AndroidGattOperationFailure) -> Unit
154
219
  ) {
155
- private val provisionalFailures = ConcurrentHashMap<Key, AndroidGattOperationFailure>()
220
+ private data class DeferredFailure(
221
+ val failure: AndroidGattOperationFailure
222
+ )
223
+
224
+ private val provisionalFailures = ConcurrentHashMap<Key, DeferredFailure>()
156
225
 
157
226
  fun defer(key: Key, failure: AndroidGattOperationFailure) {
158
- if (provisionalFailures.putIfAbsent(key, failure) != null) return
227
+ val deferred = DeferredFailure(failure)
228
+ if (provisionalFailures.putIfAbsent(key, deferred) != null) return
159
229
  val fallback: () -> Unit = {
160
- val retained = provisionalFailures.remove(key)
161
- if (retained != null) onFallback(key, retained)
230
+ if (provisionalFailures.remove(key, deferred)) onFallback(key, deferred.failure)
162
231
  }
163
- if (!schedule(ANDROID_CCCD_DISCONNECT_EVIDENCE_GRACE_MS, fallback)) fallback()
232
+ if (!schedule(ANDROID_GATT_DISCONNECT_EVIDENCE_GRACE_MS, fallback)) fallback()
164
233
  }
165
234
 
166
- fun claim(key: Key): AndroidGattOperationFailure? = provisionalFailures.remove(key)
235
+ fun claim(key: Key): AndroidGattOperationFailure? = provisionalFailures.remove(key)?.failure
167
236
 
168
237
  fun isPending(key: Key): Boolean = provisionalFailures.containsKey(key)
169
238
 
@@ -172,6 +241,26 @@ internal class AndroidCccdTerminalArbiter<Key>(
172
241
  }
173
242
  }
174
243
 
244
+ /**
245
+ * Applies the characteristic-write callback policy at the radio boundary.
246
+ * Returns true when the callback was deferred for lifecycle arbitration.
247
+ */
248
+ internal fun <Key> AndroidGattTerminalArbiter<Key>.deferCharacteristicWriteFailure(
249
+ key: Key,
250
+ status: Int
251
+ ): Boolean {
252
+ val failure = classifyAndroidGattOperationFailure("characteristic-write", status)
253
+ if (!shouldAwaitAndroidWriteDisconnectEvidence(failure)) return false
254
+ defer(key, failure)
255
+ return true
256
+ }
257
+
258
+ /** Kept as a descriptive alias for existing CCCD callers and tests. */
259
+ internal typealias AndroidCccdTerminalArbiter<Key> = AndroidGattTerminalArbiter<Key>
260
+
261
+ /** Descriptive alias for characteristic-write terminal arbitration. */
262
+ internal typealias AndroidWriteTerminalArbiter<Key> = AndroidGattTerminalArbiter<Key>
263
+
175
264
  /** Synchronous API rejection after local CCCD registration, before Android starts ATT work. */
176
265
  internal class AndroidCccdSubmissionFailure(
177
266
  val platformStatus: Int?
@@ -361,8 +450,8 @@ class OwnedAndroidGattRadio(private val context: Context) {
361
450
  private val pendingPhyRequests = ConcurrentHashMap<String, (Result<OwnedAndroidPhy?>) -> Unit>()
362
451
  private val pendingDesc = ConcurrentHashMap<String, (Result<Unit>) -> Unit>()
363
452
  private val pendingDescRead = ConcurrentHashMap<String, (Result<ByteArray?>) -> Unit>()
364
- /** Stashed write payloads so API-33 callbacks need not read deprecated characteristic.value. */
365
- private val pendingWriteValues = ConcurrentHashMap<String, ByteArray>()
453
+ /** Identity-bearing owners for the legacy UUID-keyed write callback path. */
454
+ private val pendingWriteRegistry = AndroidPendingWriteRegistry<BluetoothGattCharacteristic>()
366
455
  private val pendingBondPairs =
367
456
  ConcurrentHashMap<String, (String, OwnedAndroidSecurityState) -> Unit>()
368
457
 
@@ -404,6 +493,22 @@ class OwnedAndroidGattRadio(private val context: Context) {
404
493
  completeExactUnit(pending, Result.failure(failure))
405
494
  }
406
495
  )
496
+ private val exactWriteTerminalArbiter = AndroidWriteTerminalArbiter<BluetoothGattCharacteristic>(
497
+ schedule = { delayMs, action -> mainHandler.postDelayed(action, delayMs) },
498
+ onFallback = fallback@ { characteristic, failure ->
499
+ val observedPayload = exactWriteValues[characteristic]
500
+ val pending = exactWritePending.remove(characteristic) ?: return@fallback
501
+ removeAndroidWritePayloadIfSame(exactWriteValues, characteristic, observedPayload)
502
+ completeExactByte(pending, Result.failure(failure))
503
+ }
504
+ )
505
+ private val writeTerminalArbiter = AndroidWriteTerminalArbiter<String>(
506
+ schedule = { delayMs, action -> mainHandler.postDelayed(action, delayMs) },
507
+ onFallback = fallback@ { key, failure ->
508
+ val owner = pendingWriteRegistry.remove(key) ?: return@fallback
509
+ owner.callback(Result.failure(failure))
510
+ }
511
+ )
407
512
  private val exactDescriptorReadPending = ConcurrentHashMap<BluetoothGattDescriptor, ExactBytePending>()
408
513
  private val exactDescriptorWritePending = ConcurrentHashMap<BluetoothGattDescriptor, ExactUnitPending>()
409
514
  private val activeNativeSubscriptionOwnership =
@@ -1302,23 +1407,29 @@ class OwnedAndroidGattRadio(private val context: Context) {
1302
1407
  else BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1303
1408
  ch.writeType = writeType
1304
1409
  val key = pendingCharKey("write", deviceId, serviceUuid, charUuid)
1305
- // Stash payload for callback success (API 33 does not require ch.value).
1306
- pendingWriteValues[key] = value
1307
- pending[key] = { r ->
1410
+ val owner = AndroidPendingWriteOwner(
1411
+ deviceKeyUpper = deviceId.uppercase(),
1412
+ attribute = ch,
1413
+ value = value.copyOf()
1414
+ ) { r ->
1308
1415
  if (!token.isPubliclySettled()) {
1309
1416
  onResult(r)
1310
1417
  }
1311
1418
  done()
1312
1419
  }
1420
+ if (!pendingWriteRegistry.putIfAbsent(key, owner)) {
1421
+ onResult(Result.failure(IllegalStateException("characteristic write is already pending")))
1422
+ done()
1423
+ return@enqueue
1424
+ }
1313
1425
  if (Build.VERSION.SDK_INT >= 33) {
1314
1426
  val status = gatt.writeCharacteristic(ch, value, writeType)
1315
1427
  if (!acceptApi33WriteStatus(status)) {
1316
- pending.remove(key)
1317
- pendingWriteValues.remove(key)
1318
- onResult(
1319
- Result.failure(IllegalStateException("writeCharacteristic failed to start status=$status"))
1320
- )
1321
- done()
1428
+ if (pendingWriteRegistry.remove(key, owner)) {
1429
+ owner.callback(
1430
+ Result.failure(classifyAndroidGattOperationFailure("characteristic-write", status))
1431
+ )
1432
+ }
1322
1433
  }
1323
1434
  } else {
1324
1435
  @Suppress("DEPRECATION")
@@ -1326,10 +1437,9 @@ class OwnedAndroidGattRadio(private val context: Context) {
1326
1437
  @Suppress("DEPRECATION")
1327
1438
  val started = gatt.writeCharacteristic(ch)
1328
1439
  if (!started) {
1329
- pending.remove(key)
1330
- pendingWriteValues.remove(key)
1331
- onResult(Result.failure(IllegalStateException("writeCharacteristic failed to start")))
1332
- done()
1440
+ if (pendingWriteRegistry.remove(key, owner)) {
1441
+ owner.callback(Result.failure(IllegalStateException("writeCharacteristic failed to start")))
1442
+ }
1333
1443
  }
1334
1444
  }
1335
1445
  }
@@ -1416,11 +1526,12 @@ class OwnedAndroidGattRadio(private val context: Context) {
1416
1526
  if (Build.VERSION.SDK_INT >= 33) {
1417
1527
  val status = gatt.writeCharacteristic(characteristic, value, writeType)
1418
1528
  if (!acceptApi33WriteStatus(status)) {
1419
- exactWriteValues.remove(characteristic)
1529
+ val observedPayload = exactWriteValues[characteristic]
1420
1530
  if (exactWritePending.remove(characteristic, pending)) {
1531
+ removeAndroidWritePayloadIfSame(exactWriteValues, characteristic, observedPayload)
1421
1532
  completeExactByte(
1422
1533
  pending,
1423
- Result.failure(IllegalStateException("writeCharacteristic failed to start status=$status"))
1534
+ Result.failure(classifyAndroidGattOperationFailure("characteristic-write", status))
1424
1535
  )
1425
1536
  }
1426
1537
  }
@@ -1430,8 +1541,9 @@ class OwnedAndroidGattRadio(private val context: Context) {
1430
1541
  @Suppress("DEPRECATION")
1431
1542
  val started = gatt.writeCharacteristic(characteristic)
1432
1543
  if (!started) {
1433
- exactWriteValues.remove(characteristic)
1544
+ val observedPayload = exactWriteValues[characteristic]
1434
1545
  if (exactWritePending.remove(characteristic, pending)) {
1546
+ removeAndroidWritePayloadIfSame(exactWriteValues, characteristic, observedPayload)
1435
1547
  completeExactByte(
1436
1548
  pending,
1437
1549
  Result.failure(IllegalStateException("writeCharacteristic failed to start"))
@@ -2166,12 +2278,14 @@ class OwnedAndroidGattRadio(private val context: Context) {
2166
2278
  pendingPhyRequests.clear()
2167
2279
  pendingDesc.clear()
2168
2280
  pendingDescRead.clear()
2169
- pendingWriteValues.clear()
2281
+ pendingWriteRegistry.clear()
2170
2282
  exactReadPending.clear()
2171
2283
  exactWritePending.clear()
2172
2284
  exactWriteValues.clear()
2173
2285
  exactCccdPending.clear()
2174
2286
  exactCccdTerminalArbiter.clear()
2287
+ exactWriteTerminalArbiter.clear()
2288
+ writeTerminalArbiter.clear()
2175
2289
  exactDescriptorReadPending.clear()
2176
2290
  exactDescriptorWritePending.clear()
2177
2291
  activeNativeSubscriptionOwnership.clear()
@@ -2240,9 +2354,10 @@ class OwnedAndroidGattRadio(private val context: Context) {
2240
2354
  val failBytes = Result.failure<ByteArray?>(IllegalStateException(reason))
2241
2355
  val failInt = Result.failure<Int>(IllegalStateException(reason))
2242
2356
  val failUnit = Result.failure<Unit>(IllegalStateException(reason))
2243
- // A peer link-loss callback can win the race with onDescriptorWrite. Keep
2244
- // that exact Android status on pending CCCD work so the protocol boundary
2245
- // reports connectionLost instead of flattening the race to platformFailure.
2357
+ // A peer link-loss callback can win the race with an asynchronous GATT
2358
+ // callback. Keep that exact Android status on pending CCCD work so the
2359
+ // protocol boundary reports connectionLost instead of flattening the race
2360
+ // to platformFailure.
2246
2361
  val failCccd: Result<Unit> = if (gattStatus == ANDROID_GATT_LINK_LOSS_STATUS) {
2247
2362
  Result.failure(classifyAndroidGattOperationFailure("cccd-write", gattStatus))
2248
2363
  } else {
@@ -2251,11 +2366,12 @@ class OwnedAndroidGattRadio(private val context: Context) {
2251
2366
  pending.keys
2252
2367
  .filter { key ->
2253
2368
  keyBelongsToDevice(key, "discover", deviceKeyUpper) ||
2254
- keyBelongsToDevice(key, "read", deviceKeyUpper) ||
2255
- keyBelongsToDevice(key, "write", deviceKeyUpper)
2369
+ keyBelongsToDevice(key, "read", deviceKeyUpper)
2256
2370
  }
2257
2371
  .toList()
2258
- .forEach { key -> pending.remove(key)?.invoke(failBytes) }
2372
+ .forEach { key ->
2373
+ pending.remove(key)?.invoke(failBytes)
2374
+ }
2259
2375
  pendingMtu.remove("mtu:$deviceKeyUpper")?.invoke(failInt)
2260
2376
  pendingRssi.remove("rssi:$deviceKeyUpper")?.invoke(failInt)
2261
2377
  pendingPhyReads.remove("phyRead:$deviceKeyUpper")?.invoke(
@@ -2277,10 +2393,14 @@ class OwnedAndroidGattRadio(private val context: Context) {
2277
2393
  .filter { key -> keyBelongsToDevice(key, "descRead", deviceKeyUpper) }
2278
2394
  .toList()
2279
2395
  .forEach { key -> pendingDescRead.remove(key)?.invoke(failBytes) }
2280
- pendingWriteValues.keys
2281
- .filter { key -> keyBelongsToDevice(key, "write", deviceKeyUpper) }
2282
- .toList()
2283
- .forEach { key -> pendingWriteValues.remove(key) }
2396
+ // Remove only the owner observed by this teardown. A queued replacement
2397
+ // may reuse the same UUID key while the old disconnect callback drains.
2398
+ pendingWriteRegistry.entriesForDevice(deviceKeyUpper).forEach { (key, owner) ->
2399
+ if (pendingWriteRegistry.remove(key, owner)) {
2400
+ writeTerminalArbiter.claim(key)
2401
+ owner.callback(failBytes)
2402
+ }
2403
+ }
2284
2404
  exactReadPending.entries
2285
2405
  .filter { it.value.deviceKeyUpper == deviceKeyUpper }
2286
2406
  .forEach { entry ->
@@ -2291,8 +2411,10 @@ class OwnedAndroidGattRadio(private val context: Context) {
2291
2411
  exactWritePending.entries
2292
2412
  .filter { it.value.deviceKeyUpper == deviceKeyUpper }
2293
2413
  .forEach { entry ->
2294
- exactWriteValues.remove(entry.key)
2414
+ val observedPayload = exactWriteValues[entry.key]
2295
2415
  if (exactWritePending.remove(entry.key, entry.value)) {
2416
+ removeAndroidWritePayloadIfSame(exactWriteValues, entry.key, observedPayload)
2417
+ exactWriteTerminalArbiter.claim(entry.key)
2296
2418
  completeExactByte(entry.value, failBytes)
2297
2419
  }
2298
2420
  }
@@ -2826,34 +2948,48 @@ class OwnedAndroidGattRadio(private val context: Context) {
2826
2948
  status: Int
2827
2949
  ) {
2828
2950
  if (!isCurrentGattCallback(gatt)) return
2829
- exactWritePending.remove(characteristic)?.let { pending ->
2951
+ // A provisional callback failure must remain pending while lifecycle
2952
+ // evidence already in flight gets a chance to claim the terminal.
2953
+ if (exactWriteTerminalArbiter.isPending(characteristic)) return
2954
+ exactWritePending[characteristic]?.let { pending ->
2955
+ val observedPayload = exactWriteValues[characteristic]
2830
2956
  if (!isCurrentGatt(pending.deviceKeyUpper, gatt, pending.gattGeneration)) {
2831
- exactWriteValues.remove(characteristic)
2832
- pending.done()
2957
+ if (exactWritePending.remove(characteristic, pending)) {
2958
+ exactWriteTerminalArbiter.claim(characteristic)
2959
+ removeAndroidWritePayloadIfSame(exactWriteValues, characteristic, observedPayload)
2960
+ pending.done()
2961
+ }
2833
2962
  return@let
2834
2963
  }
2835
- val written = exactWriteValues.remove(characteristic)
2836
2964
  if (status == BluetoothGatt.GATT_SUCCESS) {
2837
- completeExactByte(pending, Result.success(written?.copyOf()))
2965
+ if (exactWritePending.remove(characteristic, pending)) {
2966
+ removeAndroidWritePayloadIfSame(exactWriteValues, characteristic, observedPayload)
2967
+ completeExactByte(pending, Result.success(observedPayload?.copyOf()))
2968
+ }
2838
2969
  } else {
2839
- completeExactByte(pending, Result.failure(IllegalStateException("write status=$status")))
2970
+ if (exactWriteTerminalArbiter.deferCharacteristicWriteFailure(characteristic, status)) {
2971
+ return@let
2972
+ }
2973
+ val failure = classifyAndroidGattOperationFailure("characteristic-write", status)
2974
+ if (exactWritePending.remove(characteristic, pending)) {
2975
+ removeAndroidWritePayloadIfSame(exactWriteValues, characteristic, observedPayload)
2976
+ completeExactByte(pending, Result.failure(failure))
2977
+ }
2840
2978
  }
2841
2979
  return
2842
2980
  }
2843
2981
  val id = gatt.device.address.uppercase()
2844
2982
  val key = charPendingKeyFromGatt("write", id, characteristic) ?: return
2845
- val stashed = pendingWriteValues.remove(key)
2983
+ if (writeTerminalArbiter.isPending(key)) return
2846
2984
  if (status == BluetoothGatt.GATT_SUCCESS) {
2847
- // Prefer stashed payload (API 33 path never wrote ch.value).
2848
- val value =
2849
- stashed
2850
- ?: run {
2851
- @Suppress("DEPRECATION")
2852
- characteristic.value
2853
- }
2854
- pending.remove(key)?.invoke(Result.success(value))
2985
+ // The owner retains the API-33 payload and rejects stale callbacks
2986
+ // after a same-key replacement has taken ownership.
2987
+ val owner = pendingWriteRegistry.get(key) ?: return
2988
+ pendingWriteRegistry.complete(key, characteristic, Result.success(owner.value.copyOf()))
2855
2989
  } else {
2856
- pending.remove(key)?.invoke(Result.failure(IllegalStateException("write status=$status")))
2990
+ if (writeTerminalArbiter.deferCharacteristicWriteFailure(key, status)) return
2991
+ val failure = classifyAndroidGattOperationFailure("characteristic-write", status)
2992
+ pendingWriteRegistry.complete(key, characteristic, Result.failure(failure))
2857
2993
  }
2858
2994
  }
2859
2995
 
@@ -3,6 +3,7 @@
3
3
  package com.sfourdrinier.unifiedblemanager.protocol
4
4
 
5
5
  import com.sfourdrinier.unifiedblemanager.protocol.generated.RecordKind
6
+ import com.sfourdrinier.unifiedblemanager.radio.deferCharacteristicWriteFailure
6
7
  import com.sfourdrinier.unifiedblemanager.radio.nextUuidOccurrence
7
8
  import com.sfourdrinier.unifiedblemanager.radio.resolveUuidOccurrence
8
9
  import com.sfourdrinier.unifiedblemanager.radio.OwnedAndroidGattRadio
@@ -18,9 +19,14 @@ import com.sfourdrinier.unifiedblemanager.radio.AndroidGattOperationFailure
18
19
  import com.sfourdrinier.unifiedblemanager.radio.AndroidCccdSubmissionFailure
19
20
  import com.sfourdrinier.unifiedblemanager.radio.AndroidNotificationRollbackRejected
20
21
  import com.sfourdrinier.unifiedblemanager.radio.AndroidCccdTerminalArbiter
22
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidWriteTerminalArbiter
23
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidPendingWriteOwner
24
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidPendingWriteRegistry
25
+ import com.sfourdrinier.unifiedblemanager.radio.removeAndroidWritePayloadIfSame
21
26
  import com.sfourdrinier.unifiedblemanager.radio.OwnedRadioTeardownFailure
22
27
  import com.sfourdrinier.unifiedblemanager.radio.androidGattTerminalResult
23
28
  import com.sfourdrinier.unifiedblemanager.radio.shouldAwaitAndroidCccdDisconnectEvidence
29
+ import com.sfourdrinier.unifiedblemanager.radio.shouldAwaitAndroidWriteDisconnectEvidence
24
30
  import org.junit.Assert.assertEquals
25
31
  import org.junit.Assert.assertArrayEquals
26
32
  import org.junit.Assert.assertFalse
@@ -132,6 +138,202 @@ class UnifiedBleProtocolAndroidDispatcherLifecycleTest {
132
138
  assertFalse(arbiter.isPending("cccd"))
133
139
  }
134
140
 
141
+ @Test
142
+ fun pendingWrite133ThenAuthoritativeDisconnectStatus8EmitsOneConnectionLostTerminal() {
143
+ val scheduled = mutableListOf<() -> Unit>()
144
+ val terminalKinds = mutableListOf<String>()
145
+ val arbiter = AndroidWriteTerminalArbiter<String>(
146
+ schedule = { _, action -> scheduled.add(action) },
147
+ onFallback = { _, failure ->
148
+ terminalKinds.add(androidGattOperationFailureCode(failure, "writeFailed"))
149
+ }
150
+ )
151
+ val writeFailure = classifyAndroidGattOperationFailure("characteristic-write", 133)
152
+
153
+ assertTrue(shouldAwaitAndroidWriteDisconnectEvidence(writeFailure))
154
+ assertFalse(
155
+ shouldAwaitAndroidWriteDisconnectEvidence(
156
+ classifyAndroidGattOperationFailure("characteristic-write", 19)
157
+ )
158
+ )
159
+ assertFalse(
160
+ shouldAwaitAndroidWriteDisconnectEvidence(
161
+ classifyAndroidGattOperationFailure("descriptor-write", 133)
162
+ )
163
+ )
164
+ assertTrue(arbiter.deferCharacteristicWriteFailure("write", writeFailure.gattStatus!!))
165
+
166
+ // The lifecycle callback is authoritative even though Android reports the
167
+ // transport timeout as status 8 rather than the write callback's 133.
168
+ val pendingCommands = mutableSetOf("write")
169
+ val disconnectStatus = 8
170
+ if (disconnectStatus == 8 && pendingCommands.remove("write")) {
171
+ terminalKinds.add("connectionLost")
172
+ }
173
+ val claimedFailure = arbiter.claim("write")
174
+ assertEquals("characteristic-write", claimedFailure?.operation)
175
+ assertEquals(133, claimedFailure?.gattStatus)
176
+ scheduled.single().invoke()
177
+
178
+ assertEquals(listOf("connectionLost"), terminalKinds)
179
+ val connection = ProtocolWireRecord(
180
+ RecordKind.CONNECTION_PATH,
181
+ mapOf(1 to ProtocolWireValue.RecordValue(ProtocolWireRecord(RecordKind.ATTACHMENT, emptyMap())))
182
+ )
183
+ val event = connectionLostEvent(1L, connection, disconnectStatus, 0L, 0L)
184
+ val error = event.requiredRecord(14)
185
+ assertEquals(ProtocolWireValue.StringValue("connectionLost"), event.fields[3])
186
+ assertEquals(ProtocolWireValue.SignedIntegerValue(8L), error.fields[8])
187
+ }
188
+
189
+ @Test
190
+ fun isolatedWrite133AfterGraceEmitsPlatformFailureRetainingOperationAndStatus() {
191
+ val scheduled = mutableListOf<() -> Unit>()
192
+ val terminalKinds = mutableListOf<String>()
193
+ var fallback: AndroidGattOperationFailure? = null
194
+ val arbiter = AndroidWriteTerminalArbiter<String>(
195
+ schedule = { _, action -> scheduled.add(action) },
196
+ onFallback = { _, failure ->
197
+ fallback = failure
198
+ terminalKinds.add(androidGattOperationFailureCode(failure, "writeFailed"))
199
+ }
200
+ )
201
+ val writeFailure = classifyAndroidGattOperationFailure("characteristic-write", 133)
202
+
203
+ assertTrue(arbiter.deferCharacteristicWriteFailure("write", writeFailure.gattStatus!!))
204
+ scheduled.single().invoke()
205
+
206
+ assertEquals(listOf("writeFailed"), terminalKinds)
207
+ assertTrue(fallback != null)
208
+ assertEquals("characteristic-write", fallback?.operation)
209
+ assertEquals(133, fallback?.gattStatus)
210
+ assertEquals(133, androidGattOperationFailureStatus(fallback!!))
211
+ assertEquals("writeFailed", androidGattOperationFailureCode(fallback!!, "writeFailed"))
212
+ }
213
+
214
+ @Test
215
+ fun writeTerminalArbiterPreventsDuplicateTerminalAfterDisconnectAndLateWriteCallback() {
216
+ val scheduled = mutableListOf<() -> Unit>()
217
+ val terminalKinds = mutableListOf<String>()
218
+ val arbiter = AndroidWriteTerminalArbiter<String>(
219
+ schedule = { _, action -> scheduled.add(action) },
220
+ onFallback = { _, failure ->
221
+ terminalKinds.add(androidGattOperationFailureCode(failure, "writeFailed"))
222
+ }
223
+ )
224
+ val writeFailure = classifyAndroidGattOperationFailure("characteristic-write", 133)
225
+
226
+ assertTrue(arbiter.deferCharacteristicWriteFailure("write", writeFailure.gattStatus!!))
227
+ assertTrue(arbiter.isPending("write"))
228
+ val claimedFailure = arbiter.claim("write")
229
+ assertEquals("characteristic-write", claimedFailure?.operation)
230
+ assertEquals(133, claimedFailure?.gattStatus)
231
+ assertTrue(!arbiter.isPending("write"))
232
+ scheduled.single().invoke()
233
+ // A late duplicate callback cannot reclaim the already-settled terminal.
234
+ assertTrue(arbiter.claim("write") == null)
235
+
236
+ val pendingCommands = mutableSetOf("write")
237
+ if (pendingCommands.remove("write")) terminalKinds.add("connectionLost")
238
+ if (pendingCommands.remove("write")) terminalKinds.add("writeFailed")
239
+ assertEquals(listOf("connectionLost"), terminalKinds)
240
+ }
241
+
242
+ @Test
243
+ fun pendingWriteReplacementIsIdentitySafeDuringOldDisconnectTeardownAndLateCallback() {
244
+ val registry = AndroidPendingWriteRegistry<String>()
245
+ val terminals = mutableListOf<String>()
246
+ val oldAttribute = "old-characteristic"
247
+ val replacementAttribute = "replacement-characteristic"
248
+ val oldOwner = AndroidPendingWriteOwner(
249
+ deviceKeyUpper = "AA:BB",
250
+ attribute = oldAttribute,
251
+ value = byteArrayOf(1)
252
+ ) { terminals.add("old") }
253
+ val replacementOwner = AndroidPendingWriteOwner(
254
+ deviceKeyUpper = "AA:BB",
255
+ attribute = replacementAttribute,
256
+ value = byteArrayOf(2)
257
+ ) { terminals.add("replacement") }
258
+
259
+ assertTrue(registry.putIfAbsent("write:key", oldOwner))
260
+ // Teardown observed the old owner, but a replacement acquired the key
261
+ // before the stale disconnect work finished.
262
+ assertTrue(registry.remove("write:key", oldOwner))
263
+ assertTrue(registry.putIfAbsent("write:key", replacementOwner))
264
+ assertFalse(registry.remove("write:key", oldOwner))
265
+ assertFalse(
266
+ registry.complete(
267
+ "write:key",
268
+ oldAttribute,
269
+ Result.success(byteArrayOf(9))
270
+ )
271
+ )
272
+ assertEquals(listOf<String>(), terminals)
273
+ assertTrue(registry.complete("write:key", replacementAttribute, Result.success(byteArrayOf(2))))
274
+ assertEquals(listOf("replacement"), terminals)
275
+ }
276
+
277
+ @Test
278
+ fun exactWriteTeardownOrRejectionCannotDeleteReplacementPayload() {
279
+ val values = ConcurrentHashMap<String, ByteArray>()
280
+ val oldPayload = byteArrayOf(1)
281
+ val replacementPayload = byteArrayOf(2)
282
+ values["write:key"] = oldPayload
283
+
284
+ // Capture the old payload before conditional owner removal, then let the
285
+ // replacement acquire the same key before stale cleanup runs.
286
+ val observed = values["write:key"]
287
+ values["write:key"] = replacementPayload
288
+ assertFalse(removeAndroidWritePayloadIfSame(values, "write:key", observed))
289
+ assertTrue(values["write:key"] === replacementPayload)
290
+ }
291
+
292
+ @Test
293
+ fun writeArbiterClearCannotLetPostedFallbackSettleReusedKey() {
294
+ val scheduled = mutableListOf<() -> Unit>()
295
+ val fallbacks = mutableListOf<AndroidGattOperationFailure>()
296
+ val arbiter = AndroidWriteTerminalArbiter<String>(
297
+ schedule = { _, action -> scheduled.add(action) },
298
+ onFallback = { _, failure -> fallbacks.add(failure) }
299
+ )
300
+ val first = classifyAndroidGattOperationFailure("characteristic-write", 133)
301
+ val replacement = classifyAndroidGattOperationFailure("characteristic-write", 257)
302
+
303
+ arbiter.defer("write", first)
304
+ arbiter.clear()
305
+ arbiter.defer("write", replacement)
306
+ scheduled[0].invoke()
307
+ assertTrue(arbiter.isPending("write"))
308
+ assertEquals(listOf<AndroidGattOperationFailure>(), fallbacks)
309
+ scheduled[1].invoke()
310
+ assertEquals(listOf(replacement), fallbacks)
311
+ }
312
+
313
+ @Test
314
+ fun characteristicWriteCallbacksAndDispatcherPreserveTypedFailurePolicy() {
315
+ val radio = readAndroidSource(
316
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt"
317
+ )
318
+ val callback = radio.substring(
319
+ radio.indexOf("override fun onCharacteristicWrite"),
320
+ radio.indexOf("@Deprecated", radio.indexOf("override fun onCharacteristicWrite"))
321
+ )
322
+ assertEquals(2, Regex("deferCharacteristicWriteFailure\\(").findAll(callback).count())
323
+ assertTrue(callback.contains("pendingWriteRegistry.complete(key, characteristic"))
324
+ assertTrue(callback.contains("classifyAndroidGattOperationFailure(\"characteristic-write\", status)"))
325
+
326
+ val dispatcher = readAndroidSource(
327
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcher.kt"
328
+ )
329
+ val write = dispatcher.substring(
330
+ dispatcher.indexOf("private fun write(command: ProtocolWireRecord)"),
331
+ dispatcher.indexOf("private fun writeDescriptor(command: ProtocolWireRecord)")
332
+ )
333
+ assertTrue(write.contains("emitGattOperationFailure(command, \"writeFailed\", error"))
334
+ assertFalse(write.contains("emitFailure(command, \"writeFailed\", error.message"))
335
+ }
336
+
135
337
  @Test
136
338
  fun dispatcherOnlyMapsTypedLinkLossToConnectionLost() {
137
339
  val linkLoss = classifyAndroidGattOperationFailure("cccd-write", 19)
package/docs/ELECTRON.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Main owns the radio. The renderer uses a versioned IPC client and never loads a native addon.
6
6
 
7
- This source targets `4.0.23`. Tagged releases ship Node-API v8 prebuilds covering macOS and Windows `arm64`/`x64` for both Node and modern Electron.
7
+ This source targets `4.0.24`. Tagged releases ship Node-API v8 prebuilds covering macOS and Windows `arm64`/`x64` for both Node and modern Electron.
8
8
 
9
9
  `unified-ble-manager/electron/main` and
10
10
  `unified-ble-manager/electron/renderer` are the only Electron entrypoints.
@@ -7,7 +7,7 @@ start a radio, request runtime permissions during prebuild, or prove physical
7
7
  radio/restoration reliability. Expo Go is not a supported BLE execution
8
8
  environment because it cannot contain this native module.
9
9
 
10
- Use the v2 plugin options in this `4.0.23` source. Those options match
10
+ Use the v2 plugin options in this `4.0.24` source. Those options match
11
11
  the schema introduced at `4.0.0-rc.4`. Expo Go cannot load this native module.
12
12
 
13
13
  ## Installation and development build
package/docs/GAPS.4.0.md CHANGED
@@ -14,7 +14,7 @@ This file tracks platform code, CI, package, lab, and live-radio evidence. It do
14
14
 
15
15
  The clean-baseline contract, unified core, public manager, deterministic backend, TCK, native protocol, first-party backend implementations, host-isolated package exports, SDK/CLI, and legacy-absence gates exist in the 4.0 source. Passing deterministic, compile, ABI, or package tests are implementation proof; they do not become physical-radio support evidence unless a retained record proves the corresponding live scenario.
16
16
 
17
- This source targets `unified-ble-manager@4.0.23`; the npm registry and release
17
+ This source targets `unified-ble-manager@4.0.24`; the npm registry and release
18
18
  provenance, not a source version string, determine whether it is published.
19
19
  Earlier stable releases remain immutable published history. Backend support labels remain
20
20
  evidence-derived. This package does not rewrite the evidence inventory: a backend remains at the support level
@@ -4,7 +4,7 @@
4
4
 
5
5
  This page gets you to a first scan, connect, read, notify, and teardown on React Native. Other hosts are linked at the bottom. The root import does not turn Bluetooth on.
6
6
 
7
- This source targets `4.0.23`; verify the published version in the npm registry.
7
+ This source targets `4.0.24`; verify the published version in the npm registry.
8
8
 
9
9
  ## Pick a host
10
10
 
@@ -38,11 +38,11 @@ request runtime permissions on Android 12+, add
38
38
  #### Expo / CNG v2
39
39
 
40
40
  The Expo v2 schema and `unified-ble-manager/expo` factory are in this source.
41
- After the npm registry lists `4.0.23`, install that exact version and keep it in
41
+ After the npm registry lists `4.0.24`, install that exact version and keep it in
42
42
  your lockfile while validating the native build:
43
43
 
44
44
  ```sh
45
- pnpm add unified-ble-manager@4.0.23
45
+ pnpm add unified-ble-manager@4.0.24
46
46
  ```
47
47
 
48
48
  The package does not run in Expo Go.
package/docs/NODE.md CHANGED
@@ -10,7 +10,7 @@ The root import does not open an adapter. Pick one backend:
10
10
  | `unified-ble-manager/node/winrt` | Windows |
11
11
  | `unified-ble-manager/node/bluez` | Linux (needs `dbus-next@^0.10.2` in the app) |
12
12
 
13
- This source targets `4.0.23`. Tagged releases ship Node-API v8 prebuilds for macOS and Windows on `arm64` and `x64`. A normal install should not compile native code. BlueZ talks D-Bus and has no addon.
13
+ This source targets `4.0.24`. Tagged releases ship Node-API v8 prebuilds for macOS and Windows on `arm64` and `x64`. A normal install should not compile native code. BlueZ talks D-Bus and has no addon.
14
14
 
15
15
  ## One-call factories
16
16
 
package/docs/WEB.md CHANGED
@@ -6,7 +6,7 @@ Use `unified-ble-manager/web` to run the public UBM manager directly in a browse
6
6
 
7
7
  The complete runnable TypeScript/Vite application is in [`example-web/`](../example-web/).
8
8
 
9
- This guide targets `4.0.23`. Web Bluetooth support still depends on the browser, operating system, adapter, and peripheral. UBM reports those runtime boundaries; it does not fabricate a fallback backend.
9
+ This guide targets `4.0.24`. Web Bluetooth support still depends on the browser, operating system, adapter, and peripheral. UBM reports those runtime boundaries; it does not fabricate a fallback backend.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -6,7 +6,7 @@ This page is generated by `scripts/docs/generate-platform-support.js` from valid
6
6
 
7
7
  ## Current package
8
8
 
9
- `unified-ble-manager@4.0.23`
9
+ `unified-ble-manager@4.0.24`
10
10
 
11
11
  No evidence record is bound to this exact package version and artifact. Consequently, this generated page makes no Preview, Live Preview, Supported, or Reliability-qualified platform claim for the current package.
12
12
 
@@ -7,5 +7,5 @@ exports.UNIFIED_BLE_IMPLEMENTATION_VERSION = void 0;
7
7
  // src/implementation-version.ts
8
8
 
9
9
  /** The one production version stamped into every first-party backend identity. */
10
- const UNIFIED_BLE_IMPLEMENTATION_VERSION = exports.UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.23';
10
+ const UNIFIED_BLE_IMPLEMENTATION_VERSION = exports.UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.24';
11
11
  //# sourceMappingURL=implementation-version.js.map
@@ -3,5 +3,5 @@
3
3
  // src/implementation-version.ts
4
4
 
5
5
  /** The one production version stamped into every first-party backend identity. */
6
- export const UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.23';
6
+ export const UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.24';
7
7
  //# sourceMappingURL=implementation-version.js.map
@@ -4,7 +4,7 @@ import { type BackendCompatibilityOffer } from '../../backend-contract/primitive
4
4
  import { type BluezBusKind, type BluezDbusBoundaryFactory } from './bluez-dbus-contract';
5
5
  export declare const BLUEZ_BACKEND_ID = "unified-ble:bluez-dbus";
6
6
  export declare const BLUEZ_PLATFORM_ID = "unified-ble:linux-bluez";
7
- export declare const BLUEZ_IMPLEMENTATION_VERSION = "4.0.23";
7
+ export declare const BLUEZ_IMPLEMENTATION_VERSION = "4.0.24";
8
8
  export declare const bluezCompatibility: BackendCompatibilityOffer;
9
9
  export interface BluezBackendProviderOptions {
10
10
  readonly busKind: BluezBusKind;
@@ -1,7 +1,7 @@
1
1
  import { type FeatureRegistry } from '../../backend-contract/capabilities';
2
2
  export declare const COREBLUETOOTH_BACKEND_ID = "unified-ble:corebluetooth";
3
3
  export declare const COREBLUETOOTH_PLATFORM_ID = "unified-ble:macos-corebluetooth";
4
- export declare const COREBLUETOOTH_IMPLEMENTATION_VERSION = "4.0.23";
4
+ export declare const COREBLUETOOTH_IMPLEMENTATION_VERSION = "4.0.24";
5
5
  /** Identity metadata for a direct-GATT boundary that shares this backend core. */
6
6
  export interface DirectGattBackendIdentityOptions {
7
7
  readonly registeredBackendId: string;
@@ -9,7 +9,7 @@ import { ReactNativeAndroidProtocolBoundary } from '../../native-protocol/rn-and
9
9
  import { ReactNativeRestorationCoordinator, type ReactNativeRestorationActivation, type ReactNativeRestorationBackendProvider } from './react-native-restoration';
10
10
  export declare const REACT_NATIVE_ANDROID_BACKEND_ID = "unified-ble:react-native-android";
11
11
  export declare const REACT_NATIVE_ANDROID_PLATFORM_ID = "unified-ble:android-gatt";
12
- export declare const REACT_NATIVE_ANDROID_IMPLEMENTATION_VERSION = "4.0.23";
12
+ export declare const REACT_NATIVE_ANDROID_IMPLEMENTATION_VERSION = "4.0.24";
13
13
  export declare const REACT_NATIVE_ANDROID_DEFAULT_ADAPTER_NATIVE_ID = "android-default-adapter";
14
14
  export declare const reactNativeAndroidCompatibility: NativeCompatibilityOffer;
15
15
  export interface ReactNativeAndroidBackendProviderOptions {
@@ -3,7 +3,7 @@ import type { Spec as NativeProtocolControl } from '../../NativeUnifiedBleProtoc
3
3
  import { type ReactNativeRestorationBackendProvider } from './react-native-restoration';
4
4
  export declare const REACT_NATIVE_APPLE_BACKEND_ID = "unified-ble:react-native-apple";
5
5
  export declare const REACT_NATIVE_APPLE_PLATFORM_ID = "unified-ble:apple-corebluetooth";
6
- export declare const REACT_NATIVE_APPLE_IMPLEMENTATION_VERSION = "4.0.23";
6
+ export declare const REACT_NATIVE_APPLE_IMPLEMENTATION_VERSION = "4.0.24";
7
7
  export declare const REACT_NATIVE_APPLE_DEFAULT_ADAPTER_NATIVE_ID = "apple-corebluetooth-default-adapter";
8
8
  export declare const reactNativeAppleCompatibility: NativeCompatibilityOffer;
9
9
  export interface ReactNativeAppleBackendProviderOptions {
@@ -3,7 +3,7 @@ import { type BackendCompatibilityOffer } from '../../backend-contract/primitive
3
3
  import { type WinRtAdapterRecord, type WinRtBoundary } from './winrt-boundary';
4
4
  export declare const WINRT_BACKEND_ID = "unified-ble:winrt";
5
5
  export declare const WINRT_PLATFORM_ID = "unified-ble:windows-winrt";
6
- export declare const WINRT_IMPLEMENTATION_VERSION = "4.0.23";
6
+ export declare const WINRT_IMPLEMENTATION_VERSION = "4.0.24";
7
7
  export declare const winRtCompatibility: BackendCompatibilityOffer;
8
8
  export interface WinRtBackendProviderOptions {
9
9
  readonly boundaryFactory: () => WinRtBoundary;
@@ -1,3 +1,3 @@
1
1
  /** The one production version stamped into every first-party backend identity. */
2
- export declare const UNIFIED_BLE_IMPLEMENTATION_VERSION = "4.0.23";
2
+ export declare const UNIFIED_BLE_IMPLEMENTATION_VERSION = "4.0.24";
3
3
  //# sourceMappingURL=implementation-version.d.ts.map
@@ -4,7 +4,7 @@ import { type BackendCompatibilityOffer } from '../../backend-contract/primitive
4
4
  import { type BluezBusKind, type BluezDbusBoundaryFactory } from './bluez-dbus-contract.js';
5
5
  export declare const BLUEZ_BACKEND_ID = "unified-ble:bluez-dbus";
6
6
  export declare const BLUEZ_PLATFORM_ID = "unified-ble:linux-bluez";
7
- export declare const BLUEZ_IMPLEMENTATION_VERSION = "4.0.23";
7
+ export declare const BLUEZ_IMPLEMENTATION_VERSION = "4.0.24";
8
8
  export declare const bluezCompatibility: BackendCompatibilityOffer;
9
9
  export interface BluezBackendProviderOptions {
10
10
  readonly busKind: BluezBusKind;
@@ -1,7 +1,7 @@
1
1
  import { type FeatureRegistry } from '../../backend-contract/capabilities.js';
2
2
  export declare const COREBLUETOOTH_BACKEND_ID = "unified-ble:corebluetooth";
3
3
  export declare const COREBLUETOOTH_PLATFORM_ID = "unified-ble:macos-corebluetooth";
4
- export declare const COREBLUETOOTH_IMPLEMENTATION_VERSION = "4.0.23";
4
+ export declare const COREBLUETOOTH_IMPLEMENTATION_VERSION = "4.0.24";
5
5
  /** Identity metadata for a direct-GATT boundary that shares this backend core. */
6
6
  export interface DirectGattBackendIdentityOptions {
7
7
  readonly registeredBackendId: string;
@@ -9,7 +9,7 @@ import { ReactNativeAndroidProtocolBoundary } from '../../native-protocol/rn-and
9
9
  import { ReactNativeRestorationCoordinator, type ReactNativeRestorationActivation, type ReactNativeRestorationBackendProvider } from './react-native-restoration.js';
10
10
  export declare const REACT_NATIVE_ANDROID_BACKEND_ID = "unified-ble:react-native-android";
11
11
  export declare const REACT_NATIVE_ANDROID_PLATFORM_ID = "unified-ble:android-gatt";
12
- export declare const REACT_NATIVE_ANDROID_IMPLEMENTATION_VERSION = "4.0.23";
12
+ export declare const REACT_NATIVE_ANDROID_IMPLEMENTATION_VERSION = "4.0.24";
13
13
  export declare const REACT_NATIVE_ANDROID_DEFAULT_ADAPTER_NATIVE_ID = "android-default-adapter";
14
14
  export declare const reactNativeAndroidCompatibility: NativeCompatibilityOffer;
15
15
  export interface ReactNativeAndroidBackendProviderOptions {
@@ -3,7 +3,7 @@ import type { Spec as NativeProtocolControl } from '../../NativeUnifiedBleProtoc
3
3
  import { type ReactNativeRestorationBackendProvider } from './react-native-restoration.js';
4
4
  export declare const REACT_NATIVE_APPLE_BACKEND_ID = "unified-ble:react-native-apple";
5
5
  export declare const REACT_NATIVE_APPLE_PLATFORM_ID = "unified-ble:apple-corebluetooth";
6
- export declare const REACT_NATIVE_APPLE_IMPLEMENTATION_VERSION = "4.0.23";
6
+ export declare const REACT_NATIVE_APPLE_IMPLEMENTATION_VERSION = "4.0.24";
7
7
  export declare const REACT_NATIVE_APPLE_DEFAULT_ADAPTER_NATIVE_ID = "apple-corebluetooth-default-adapter";
8
8
  export declare const reactNativeAppleCompatibility: NativeCompatibilityOffer;
9
9
  export interface ReactNativeAppleBackendProviderOptions {
@@ -3,7 +3,7 @@ import { type BackendCompatibilityOffer } from '../../backend-contract/primitive
3
3
  import { type WinRtAdapterRecord, type WinRtBoundary } from './winrt-boundary.js';
4
4
  export declare const WINRT_BACKEND_ID = "unified-ble:winrt";
5
5
  export declare const WINRT_PLATFORM_ID = "unified-ble:windows-winrt";
6
- export declare const WINRT_IMPLEMENTATION_VERSION = "4.0.23";
6
+ export declare const WINRT_IMPLEMENTATION_VERSION = "4.0.24";
7
7
  export declare const winRtCompatibility: BackendCompatibilityOffer;
8
8
  export interface WinRtBackendProviderOptions {
9
9
  readonly boundaryFactory: () => WinRtBoundary;
@@ -1,3 +1,3 @@
1
1
  /** The one production version stamped into every first-party backend identity. */
2
- export declare const UNIFIED_BLE_IMPLEMENTATION_VERSION = "4.0.23";
2
+ export declare const UNIFIED_BLE_IMPLEMENTATION_VERSION = "4.0.24";
3
3
  //# sourceMappingURL=implementation-version.d.ts.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "package": "unified-ble-manager@4.0.23",
3
+ "package": "unified-ble-manager@4.0.24",
4
4
  "nodeApiVersion": 8,
5
5
  "entries": [
6
6
  {
@@ -10,7 +10,7 @@
10
10
  "nodeApiVersion": 8,
11
11
  "path": "native/electron/corebluetooth/prebuilds/darwin-arm64/unified_ble_corebluetooth.node",
12
12
  "bytes": 332368,
13
- "sha256": "f7e726489d009b28133cef3d64c50cc547fa8aa71f9ae62f9318c3d63d490062"
13
+ "sha256": "5167159b256e1c76ffc2e4080f124785cebfe3e598451ac0ece0eec74cc97d90"
14
14
  },
15
15
  {
16
16
  "backend": "corebluetooth",
@@ -19,7 +19,7 @@
19
19
  "nodeApiVersion": 8,
20
20
  "path": "native/electron/corebluetooth/prebuilds/darwin-x64/unified_ble_corebluetooth.node",
21
21
  "bytes": 314872,
22
- "sha256": "9862add5b521a780ac2db6473a2d8853a4e313049d8dc1e61450324f211f2135"
22
+ "sha256": "9deda9a0f8c4e6f07e688fdbeb5b6b631773863dc8b4de4c5bfaae8d4aaa3c9c"
23
23
  },
24
24
  {
25
25
  "backend": "winrt",
@@ -28,7 +28,7 @@
28
28
  "nodeApiVersion": 8,
29
29
  "path": "native/electron/winrt/prebuilds/win32-arm64/unified_ble_winrt.node",
30
30
  "bytes": 644608,
31
- "sha256": "678516156c65ae3c584a517219dcedbadd313d119dc5922de2bd6b952ed5faaa"
31
+ "sha256": "474be5c5179de1eff20f87f88756f4d115412570483a51d78d66605a0f05292d"
32
32
  },
33
33
  {
34
34
  "backend": "winrt",
@@ -37,7 +37,7 @@
37
37
  "nodeApiVersion": 8,
38
38
  "path": "native/electron/winrt/prebuilds/win32-x64/unified_ble_winrt.node",
39
39
  "bytes": 614400,
40
- "sha256": "8adb582faa716666ca75c77b58b01df036daa7ba28f095620dd657dbcdb30f5f"
40
+ "sha256": "48d3be097c0faa66ef6cdc53b8fb7b6543e5d71cfa327aaa07b358d2472e3383"
41
41
  }
42
42
  ]
43
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unified-ble-manager",
3
- "version": "4.0.23",
3
+ "version": "4.0.24",
4
4
  "description": "Unified Bluetooth Low Energy manager for React Native, Electron, Tauri, Web, and desktop",
5
5
  "packageManager": "pnpm@10.14.0",
6
6
  "main": "lib/commonjs/index.js",
@@ -1,4 +1,4 @@
1
1
  // src/implementation-version.ts
2
2
 
3
3
  /** The one production version stamped into every first-party backend identity. */
4
- export const UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.23'
4
+ export const UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.24'