unified-ble-manager 4.0.20 → 4.0.22

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 (36) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/MIGRATION_4.0.md +1 -1
  3. package/README.md +1 -1
  4. package/RELEASE.md +32 -2
  5. package/SBOM.cdx.json +4 -4
  6. package/THIRD_PARTY_LICENSES.json +1 -1
  7. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt +146 -25
  8. package/android/src/test/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcherLifecycleTest.kt +260 -0
  9. package/docs/ELECTRON.md +1 -1
  10. package/docs/EXPO_PLUGIN.md +1 -1
  11. package/docs/GAPS.4.0.md +1 -1
  12. package/docs/GETTING_STARTED.md +3 -3
  13. package/docs/NODE.md +1 -1
  14. package/docs/WEB.md +1 -1
  15. package/docs/generated/PLATFORM_SUPPORT.md +1 -1
  16. package/lib/commonjs/implementation-version.js +1 -1
  17. package/lib/module/implementation-version.js +1 -1
  18. package/lib/typescript/commonjs/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  19. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  20. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  21. package/lib/typescript/commonjs/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  22. package/lib/typescript/commonjs/src/backends/winrt/winrt-provider.d.ts +1 -1
  23. package/lib/typescript/commonjs/src/implementation-version.d.ts +1 -1
  24. package/lib/typescript/module/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  25. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  26. package/lib/typescript/module/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  27. package/lib/typescript/module/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  28. package/lib/typescript/module/src/backends/winrt/winrt-provider.d.ts +1 -1
  29. package/lib/typescript/module/src/implementation-version.d.ts +1 -1
  30. package/native/PREBUILDS.json +5 -5
  31. package/native/electron/corebluetooth/prebuilds/darwin-arm64/unified_ble_corebluetooth.node +0 -0
  32. package/native/electron/corebluetooth/prebuilds/darwin-x64/unified_ble_corebluetooth.node +0 -0
  33. package/native/electron/winrt/prebuilds/win32-arm64/unified_ble_winrt.node +0 -0
  34. package/native/electron/winrt/prebuilds/win32-x64/unified_ble_winrt.node +0 -0
  35. package/package.json +1 -1
  36. package/src/implementation-version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ All notable changes to `unified-ble-manager` are documented here.
6
6
 
7
7
  No changes yet.
8
8
 
9
+ ## [4.0.22] - 2026-09-04
10
+
11
+ ### Fixes
12
+
13
+ - Arbitrate an Android asynchronous CCCD failure against an already-in-flight
14
+ disconnect callback for the same GATT generation. Android can deliver status
15
+ 133 from `onDescriptorWrite`, accept local notification rollback, and only
16
+ then deliver the authoritative status-19 disconnect; exact subscriptions now
17
+ retain that provisional failure for a bounded 250 ms evidence window so the
18
+ command reports typed `connection.lost` without hiding genuine CCCD errors.
19
+
20
+ ## [4.0.21] - 2026-09-04 (unpublished)
21
+
22
+ Publication was cancelled before the npm version check after physical Android
23
+ hardware reproduced a later callback ordering that required the 4.0.22 fix.
24
+
25
+ ### Fixes
26
+
27
+ - Preserve Android GATT status 19 when a peer disconnects while an exact or
28
+ non-exact CCCD subscription is awaiting `onDescriptorWrite`. The pending
29
+ subscription now reports typed `connection.lost` instead of generic
30
+ `platform.failure`; asynchronous CCCD status 133 remains an ordinary failure
31
+ when local-registration rollback succeeds.
32
+ - Preserve that typed primary link-loss result when exact CCCD rollback also
33
+ fails; the cleanup failure remains separately reported and retryable.
34
+ - Classify an exact synchronous CCCD descriptor-submission rejection as link
35
+ loss only when the subsequent local-registration rollback is also rejected;
36
+ genuine subscription rejections remain ordinary failures.
37
+ - Apply the same two-signal rule when Android accepts descriptor submission but
38
+ its asynchronous CCCD callback fails before the connection-state callback:
39
+ a rejected rollback proves the link is gone, while a successful rollback
40
+ preserves the original platform failure and GATT status.
41
+
9
42
  ## [4.0.20] - 2026-09-03
10
43
 
11
44
  ### 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.20`. 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.22`. 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.20`. Install the exact version shown in the npm
21
+ This source tree is versioned `4.0.22`. 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
@@ -81,7 +81,9 @@ Active `4.0.0-rc.*` release-train candidates publish to npm `latest` so a bare `
81
81
 
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
- once tagged. Stable `4.0.0` through `4.0.19` are immutable. `4.0.20` is the current train head.
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 the
86
+ current train head.
85
87
 
86
88
  ```sh
87
89
  release_candidate=4.0.0-rc.N
@@ -109,6 +111,34 @@ The first stable tag `v4.0.0` is immutable published history. Do not recreate or
109
111
  git tag -a v4.0.0 -m "v4.0.0"
110
112
  ```
111
113
 
114
+ ## Releasing 4.0.22
115
+
116
+ The `v4.0.22` tag must identify the exact current `main` commit after canonical
117
+ CI passes. Do not tag the release branch directly.
118
+
119
+ ```sh
120
+ git fetch origin --tags
121
+ git checkout main
122
+ git pull --ff-only origin main
123
+
124
+ test "$(git branch --show-current)" = "main"
125
+ test "$(node -p "require('./package.json').version")" = "4.0.22"
126
+ git diff --exit-code
127
+ git diff --cached --exit-code
128
+
129
+ git tag -a v4.0.22 -m "v4.0.22"
130
+ git push origin v4.0.22
131
+ ```
132
+
133
+ Before tagging, confirm release-note extraction finds `## [4.0.22]`.
134
+
135
+ ## Unpublished 4.0.21 tag
136
+
137
+ The immutable `v4.0.21` tag triggered workflow `33862393779`, which was
138
+ cancelled during native prebuilds before the npm version check or publication
139
+ after physical Android hardware exposed a remaining CCCD callback-order race.
140
+ It must not be recreated, moved, or published manually.
141
+
112
142
  ## Releasing 4.0.20
113
143
 
114
144
  The `v4.0.20` tag must identify the exact current `main` commit after canonical
@@ -445,7 +475,7 @@ a green publish job and a package a consumer can actually install are not the
445
475
  same claim.
446
476
 
447
477
  ```sh
448
- version=4.0.20
478
+ version=4.0.22
449
479
 
450
480
  npm view "unified-ble-manager@$version" version
451
481
  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.20",
9
+ "bom-ref": "pkg:npm/unified-ble-manager@4.0.22",
10
10
  "name": "unified-ble-manager",
11
- "version": "4.0.20",
11
+ "version": "4.0.22",
12
12
  "licenses": [
13
13
  {
14
14
  "expression": "Apache-2.0"
15
15
  }
16
16
  ],
17
- "purl": "pkg:npm/unified-ble-manager@4.0.20"
17
+ "purl": "pkg:npm/unified-ble-manager@4.0.22"
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.20",
540
+ "ref": "pkg:npm/unified-ble-manager@4.0.22",
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.20"
7
+ "version": "4.0.22"
8
8
  },
9
9
  "source": {
10
10
  "method": "pnpm-lock production graph with installed-manifest license audit",
@@ -134,6 +134,90 @@ internal fun classifyAndroidNotificationRegistrationFailure(
134
134
  operation: String
135
135
  ): AndroidGattOperationFailure = AndroidGattOperationFailure(operation, null, isLinkLoss = true)
136
136
 
137
+ /**
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.
143
+ */
144
+ internal fun shouldAwaitAndroidCccdDisconnectEvidence(failure: Throwable): Boolean =
145
+ failure is AndroidGattOperationFailure &&
146
+ failure.operation == "cccd-write" &&
147
+ !failure.isLinkLoss
148
+
149
+ private const val ANDROID_CCCD_DISCONNECT_EVIDENCE_GRACE_MS = 250L
150
+
151
+ internal class AndroidCccdTerminalArbiter<Key>(
152
+ private val schedule: (delayMs: Long, action: () -> Unit) -> Boolean,
153
+ private val onFallback: (key: Key, failure: AndroidGattOperationFailure) -> Unit
154
+ ) {
155
+ private val provisionalFailures = ConcurrentHashMap<Key, AndroidGattOperationFailure>()
156
+
157
+ fun defer(key: Key, failure: AndroidGattOperationFailure) {
158
+ if (provisionalFailures.putIfAbsent(key, failure) != null) return
159
+ val fallback: () -> Unit = {
160
+ val retained = provisionalFailures.remove(key)
161
+ if (retained != null) onFallback(key, retained)
162
+ }
163
+ if (!schedule(ANDROID_CCCD_DISCONNECT_EVIDENCE_GRACE_MS, fallback)) fallback()
164
+ }
165
+
166
+ fun claim(key: Key): AndroidGattOperationFailure? = provisionalFailures.remove(key)
167
+
168
+ fun isPending(key: Key): Boolean = provisionalFailures.containsKey(key)
169
+
170
+ fun clear() {
171
+ provisionalFailures.clear()
172
+ }
173
+ }
174
+
175
+ /** Synchronous API rejection after local CCCD registration, before Android starts ATT work. */
176
+ internal class AndroidCccdSubmissionFailure(
177
+ val platformStatus: Int?
178
+ ) : IllegalStateException(
179
+ if (platformStatus == null) "writeDescriptor failed to start"
180
+ else "writeDescriptor failed to start status=$platformStatus"
181
+ )
182
+
183
+ /** Android rejected removal of the exact local notification registration. */
184
+ internal class AndroidNotificationRollbackRejected :
185
+ IllegalStateException("setCharacteristicNotification rollback was rejected")
186
+
187
+ internal fun androidGattTerminalResult(
188
+ result: Result<Unit>,
189
+ rollbackFailure: OwnedRadioTeardownFailure?
190
+ ): Result<Unit> {
191
+ val primaryFailure = result.exceptionOrNull()
192
+ val registrationRollbackRejected =
193
+ rollbackFailure?.throwable is AndroidNotificationRollbackRejected
194
+ if (registrationRollbackRejected && primaryFailure is AndroidCccdSubmissionFailure) {
195
+ return Result.failure(classifyAndroidNotificationRegistrationFailure("cccd-write"))
196
+ }
197
+ if (
198
+ registrationRollbackRejected &&
199
+ primaryFailure is AndroidGattOperationFailure &&
200
+ primaryFailure.operation == "cccd-write" &&
201
+ !primaryFailure.isLinkLoss
202
+ ) {
203
+ return Result.failure(
204
+ AndroidGattOperationFailure(
205
+ primaryFailure.operation,
206
+ primaryFailure.gattStatus,
207
+ isLinkLoss = true
208
+ )
209
+ )
210
+ }
211
+ if (rollbackFailure == null || result.isFailure) return result
212
+ return Result.failure(
213
+ IllegalStateException(
214
+ "CCCD operation failed; CCCD rollback failed: " +
215
+ (rollbackFailure.throwable.message ?: "unknown error"),
216
+ rollbackFailure.throwable
217
+ )
218
+ )
219
+ }
220
+
137
221
  /** Runtime adapter state derived from Android hardware and granted permissions. */
138
222
  internal data class OwnedRadioAdapterProtocolState(
139
223
  val availability: String,
@@ -313,6 +397,13 @@ class OwnedAndroidGattRadio(private val context: Context) {
313
397
  private val exactWritePending = ConcurrentHashMap<BluetoothGattCharacteristic, ExactBytePending>()
314
398
  private val exactWriteValues = ConcurrentHashMap<BluetoothGattCharacteristic, ByteArray>()
315
399
  private val exactCccdPending = ConcurrentHashMap<BluetoothGattDescriptor, ExactUnitPending>()
400
+ private val exactCccdTerminalArbiter = AndroidCccdTerminalArbiter<BluetoothGattDescriptor>(
401
+ schedule = { delayMs, action -> mainHandler.postDelayed(action, delayMs) },
402
+ onFallback = fallback@ { descriptor, failure ->
403
+ val pending = exactCccdPending.remove(descriptor) ?: return@fallback
404
+ completeExactUnit(pending, Result.failure(failure))
405
+ }
406
+ )
316
407
  private val exactDescriptorReadPending = ConcurrentHashMap<BluetoothGattDescriptor, ExactBytePending>()
317
408
  private val exactDescriptorWritePending = ConcurrentHashMap<BluetoothGattDescriptor, ExactUnitPending>()
318
409
  private val activeNativeSubscriptionOwnership =
@@ -1754,7 +1845,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
1754
1845
  if (exactCccdPending.remove(cccd, pending)) {
1755
1846
  completeExactUnit(
1756
1847
  pending,
1757
- Result.failure(IllegalStateException("writeDescriptor failed to start status=$status"))
1848
+ Result.failure(AndroidCccdSubmissionFailure(status))
1758
1849
  )
1759
1850
  }
1760
1851
  }
@@ -1767,7 +1858,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
1767
1858
  if (exactCccdPending.remove(cccd, pending)) {
1768
1859
  completeExactUnit(
1769
1860
  pending,
1770
- Result.failure(IllegalStateException("writeDescriptor failed to start"))
1861
+ Result.failure(AndroidCccdSubmissionFailure(null))
1771
1862
  )
1772
1863
  }
1773
1864
  }
@@ -2080,6 +2171,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
2080
2171
  exactWritePending.clear()
2081
2172
  exactWriteValues.clear()
2082
2173
  exactCccdPending.clear()
2174
+ exactCccdTerminalArbiter.clear()
2083
2175
  exactDescriptorReadPending.clear()
2084
2176
  exactDescriptorWritePending.clear()
2085
2177
  activeNativeSubscriptionOwnership.clear()
@@ -2138,12 +2230,24 @@ class OwnedAndroidGattRadio(private val context: Context) {
2138
2230
  }
2139
2231
  }
2140
2232
 
2141
- private fun failPendingForDevice(deviceKeyUpper: String, reason: String) {
2233
+ private fun failPendingForDevice(
2234
+ deviceKeyUpper: String,
2235
+ reason: String,
2236
+ gattStatus: Int? = null
2237
+ ) {
2142
2238
  effectiveMtuByDevice.remove(deviceKeyUpper)
2143
2239
  deviceQueues.remove(deviceKeyUpper)?.clear(IllegalStateException(reason))
2144
2240
  val failBytes = Result.failure<ByteArray?>(IllegalStateException(reason))
2145
2241
  val failInt = Result.failure<Int>(IllegalStateException(reason))
2146
2242
  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.
2246
+ val failCccd: Result<Unit> = if (gattStatus == ANDROID_GATT_LINK_LOSS_STATUS) {
2247
+ Result.failure(classifyAndroidGattOperationFailure("cccd-write", gattStatus))
2248
+ } else {
2249
+ failUnit
2250
+ }
2147
2251
  pending.keys
2148
2252
  .filter { key ->
2149
2253
  keyBelongsToDevice(key, "discover", deviceKeyUpper) ||
@@ -2166,7 +2270,9 @@ class OwnedAndroidGattRadio(private val context: Context) {
2166
2270
  keyBelongsToDevice(key, "descWrite", deviceKeyUpper)
2167
2271
  }
2168
2272
  .toList()
2169
- .forEach { key -> pendingDesc.remove(key)?.invoke(failUnit) }
2273
+ .forEach { key ->
2274
+ pendingDesc.remove(key)?.invoke(if (key.startsWith("cccd:")) failCccd else failUnit)
2275
+ }
2170
2276
  pendingDescRead.keys
2171
2277
  .filter { key -> keyBelongsToDevice(key, "descRead", deviceKeyUpper) }
2172
2278
  .toList()
@@ -2194,7 +2300,11 @@ class OwnedAndroidGattRadio(private val context: Context) {
2194
2300
  .filter { it.value.deviceKeyUpper == deviceKeyUpper }
2195
2301
  .forEach { entry ->
2196
2302
  if (exactCccdPending.remove(entry.key, entry.value)) {
2197
- completeExactUnit(entry.value, failUnit)
2303
+ exactCccdTerminalArbiter.claim(entry.key)
2304
+ completeExactUnit(
2305
+ entry.value,
2306
+ if (entry.value.subscriptionEnabled != null) failCccd else failUnit
2307
+ )
2198
2308
  }
2199
2309
  }
2200
2310
  exactDescriptorReadPending.entries
@@ -2346,17 +2456,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
2346
2456
  pending.done()
2347
2457
  return
2348
2458
  }
2349
- val terminalResult = if (rollbackFailure == null) {
2350
- result
2351
- } else {
2352
- Result.failure(
2353
- IllegalStateException(
2354
- "${result.exceptionOrNull()?.message ?: "CCCD operation failed"}; " +
2355
- "CCCD rollback failed: ${rollbackFailure.throwable.message ?: "unknown error"}",
2356
- result.exceptionOrNull() ?: rollbackFailure.throwable
2357
- )
2358
- )
2359
- }
2459
+ val terminalResult = androidGattTerminalResult(result, rollbackFailure)
2360
2460
  if (terminalResult.isSuccess && !pending.token.isPubliclySettled()) {
2361
2461
  pending.subscriptionEnabled?.let { enabled ->
2362
2462
  pending.subscriptionCharacteristic?.let { characteristic ->
@@ -2403,7 +2503,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
2403
2503
  } else {
2404
2504
  OwnedRadioTeardownFailure(
2405
2505
  "cccdRollback:$deviceKeyUpper:generation=$generation",
2406
- IllegalStateException("setCharacteristicNotification rollback was rejected")
2506
+ AndroidNotificationRollbackRejected()
2407
2507
  )
2408
2508
  }
2409
2509
  } catch (throwable: Throwable) {
@@ -2411,6 +2511,16 @@ class OwnedAndroidGattRadio(private val context: Context) {
2411
2511
  }
2412
2512
  }
2413
2513
 
2514
+ private fun deferExactCccdFailure(
2515
+ descriptor: BluetoothGattDescriptor,
2516
+ pending: ExactUnitPending,
2517
+ failure: AndroidGattOperationFailure
2518
+ ) {
2519
+ if (exactCccdPending[descriptor] === pending) {
2520
+ exactCccdTerminalArbiter.defer(descriptor, failure)
2521
+ }
2522
+ }
2523
+
2414
2524
  private fun completeExactUnitDirect(
2415
2525
  callback: (Result<Unit>) -> Unit,
2416
2526
  done: () -> Unit,
@@ -2477,7 +2587,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
2477
2587
  // Always pass gatt status: status 133 etc. means failed connect, not clean disconnect.
2478
2588
  // R3-F003: close() only after STATE_DISCONNECTED (not from disconnect()/connect prior).
2479
2589
  dispatchConnectionState(id, false, status)
2480
- failPendingForDevice(key, "disconnected status=$status")
2590
+ failPendingForDevice(key, "disconnected status=$status", status)
2481
2591
  val teardownFailure = completeGattTeardown(key, gatt, generation)
2482
2592
  pendingDisconnectCallbacks.remove(key)?.invoke(teardownFailure)
2483
2593
  if (teardownFailure != null) return
@@ -2514,18 +2624,29 @@ class OwnedAndroidGattRadio(private val context: Context) {
2514
2624
  }
2515
2625
  return
2516
2626
  }
2517
- exactCccdPending.remove(descriptor)?.let { pending ->
2627
+ exactCccdPending[descriptor]?.let { pending ->
2628
+ // The first callback owns the terminal. A contradictory duplicate
2629
+ // callback cannot replace a provisional failure during arbitration.
2630
+ if (exactCccdTerminalArbiter.isPending(descriptor)) return
2518
2631
  if (!isCurrentGatt(pending.deviceKeyUpper, gatt, pending.gattGeneration)) {
2519
- pending.done()
2632
+ if (exactCccdPending.remove(descriptor, pending)) {
2633
+ exactCccdTerminalArbiter.claim(descriptor)
2634
+ pending.done()
2635
+ }
2520
2636
  return@let
2521
2637
  }
2522
2638
  if (status == BluetoothGatt.GATT_SUCCESS) {
2523
- completeExactUnit(pending, Result.success(Unit))
2639
+ if (exactCccdPending.remove(descriptor, pending)) {
2640
+ completeExactUnit(pending, Result.success(Unit))
2641
+ }
2524
2642
  } else {
2525
- completeExactUnit(
2526
- pending,
2527
- Result.failure(classifyAndroidGattOperationFailure("cccd-write", status))
2528
- )
2643
+ val failure = classifyAndroidGattOperationFailure("cccd-write", status)
2644
+ if (shouldAwaitAndroidCccdDisconnectEvidence(failure)) {
2645
+ deferExactCccdFailure(descriptor, pending, failure)
2646
+ } else if (exactCccdPending.remove(descriptor, pending)) {
2647
+ exactCccdTerminalArbiter.claim(descriptor)
2648
+ completeExactUnit(pending, Result.failure(failure))
2649
+ }
2529
2650
  }
2530
2651
  return
2531
2652
  }
@@ -14,6 +14,13 @@ import com.sfourdrinier.unifiedblemanager.radio.bondedPeerAdapterReadiness
14
14
  import com.sfourdrinier.unifiedblemanager.radio.requiresImmediateGattTeardownOnAdapterState
15
15
  import com.sfourdrinier.unifiedblemanager.radio.classifyAndroidGattOperationFailure
16
16
  import com.sfourdrinier.unifiedblemanager.radio.classifyAndroidNotificationRegistrationFailure
17
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidGattOperationFailure
18
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidCccdSubmissionFailure
19
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidNotificationRollbackRejected
20
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidCccdTerminalArbiter
21
+ import com.sfourdrinier.unifiedblemanager.radio.OwnedRadioTeardownFailure
22
+ import com.sfourdrinier.unifiedblemanager.radio.androidGattTerminalResult
23
+ import com.sfourdrinier.unifiedblemanager.radio.shouldAwaitAndroidCccdDisconnectEvidence
17
24
  import org.junit.Assert.assertEquals
18
25
  import org.junit.Assert.assertArrayEquals
19
26
  import org.junit.Assert.assertFalse
@@ -44,6 +51,87 @@ class UnifiedBleProtocolAndroidDispatcherLifecycleTest {
44
51
  assertEquals(133, ordinaryFailure.gattStatus)
45
52
  }
46
53
 
54
+ @Test
55
+ fun onlyProvisionalAsyncCccdFailuresAwaitAuthoritativeDisconnectEvidence() {
56
+ assertTrue(
57
+ shouldAwaitAndroidCccdDisconnectEvidence(
58
+ classifyAndroidGattOperationFailure("cccd-write", 133)
59
+ )
60
+ )
61
+ assertFalse(
62
+ shouldAwaitAndroidCccdDisconnectEvidence(
63
+ classifyAndroidGattOperationFailure("cccd-write", 19)
64
+ )
65
+ )
66
+ assertFalse(
67
+ shouldAwaitAndroidCccdDisconnectEvidence(
68
+ classifyAndroidGattOperationFailure("descriptor-write", 133)
69
+ )
70
+ )
71
+ assertFalse(
72
+ shouldAwaitAndroidCccdDisconnectEvidence(
73
+ IllegalStateException("ordinary failure")
74
+ )
75
+ )
76
+
77
+ val radio = readAndroidSource(
78
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt"
79
+ )
80
+ val callback = radio.substring(
81
+ radio.indexOf("override fun onDescriptorWrite"),
82
+ radio.indexOf("@Deprecated(\"Deprecated in Java\")")
83
+ )
84
+ assertTrue(callback.contains("deferExactCccdFailure(descriptor, pending, failure)"))
85
+
86
+ val disconnectCleanup = radio.substring(
87
+ radio.indexOf("exactCccdPending.entries"),
88
+ radio.indexOf("exactDescriptorReadPending.entries")
89
+ )
90
+ assertTrue(
91
+ disconnectCleanup.indexOf("exactCccdPending.remove(entry.key, entry.value)") <
92
+ disconnectCleanup.indexOf("exactCccdTerminalArbiter.claim(entry.key)")
93
+ )
94
+ }
95
+
96
+ @Test
97
+ fun cccdTerminalArbiterLetsDisconnectWinAndPreservesOrdinaryFallbackExactlyOnce() {
98
+ val scheduled = mutableListOf<() -> Unit>()
99
+ val fallbacks = mutableListOf<Pair<String, AndroidGattOperationFailure>>()
100
+ val arbiter = AndroidCccdTerminalArbiter<String>(
101
+ schedule = { _, action -> scheduled.add(action) },
102
+ onFallback = { key, failure -> fallbacks.add(key to failure) }
103
+ )
104
+ val first = classifyAndroidGattOperationFailure("cccd-write", 133)
105
+
106
+ arbiter.defer("disconnect-wins", first)
107
+ arbiter.defer("disconnect-wins", classifyAndroidGattOperationFailure("cccd-write", 257))
108
+ assertTrue(arbiter.isPending("disconnect-wins"))
109
+ assertEquals(1, scheduled.size)
110
+ assertTrue(arbiter.claim("disconnect-wins") === first)
111
+ scheduled.removeAt(0).invoke()
112
+ assertTrue(fallbacks.isEmpty())
113
+
114
+ arbiter.defer("ordinary-fallback", first)
115
+ scheduled.removeAt(0).invoke()
116
+ assertEquals(listOf("ordinary-fallback" to first), fallbacks)
117
+ assertFalse(arbiter.isPending("ordinary-fallback"))
118
+ }
119
+
120
+ @Test
121
+ fun cccdTerminalArbiterCannotStrandFailureWhenSchedulingIsRejected() {
122
+ val fallbacks = mutableListOf<AndroidGattOperationFailure>()
123
+ val arbiter = AndroidCccdTerminalArbiter<String>(
124
+ schedule = { _, _ -> false },
125
+ onFallback = { _, failure -> fallbacks.add(failure) }
126
+ )
127
+ val failure = classifyAndroidGattOperationFailure("cccd-write", 133)
128
+
129
+ arbiter.defer("cccd", failure)
130
+
131
+ assertEquals(listOf(failure), fallbacks)
132
+ assertFalse(arbiter.isPending("cccd"))
133
+ }
134
+
47
135
  @Test
48
136
  fun dispatcherOnlyMapsTypedLinkLossToConnectionLost() {
49
137
  val linkLoss = classifyAndroidGattOperationFailure("cccd-write", 19)
@@ -100,6 +188,178 @@ class UnifiedBleProtocolAndroidDispatcherLifecycleTest {
100
188
  assertFalse(radio.contains("fun isConnected("))
101
189
  }
102
190
 
191
+ @Test
192
+ fun disconnectStatus19PreservesTypedCccdFailureWhileDrainingBothSubscribePaths() {
193
+ val radio = readAndroidSource(
194
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt"
195
+ )
196
+ val pendingDrain = radio.substring(
197
+ radio.indexOf("private fun failPendingForDevice("),
198
+ radio.indexOf("private fun findChar(")
199
+ )
200
+ val disconnectCallback = radio.substring(
201
+ radio.indexOf("override fun onConnectionStateChange"),
202
+ radio.indexOf("override fun onDescriptorWrite")
203
+ )
204
+ val nonExactSubscribe = radio.substring(
205
+ radio.indexOf("private fun setNotify("),
206
+ radio.indexOf("/** Enables or disables an exact duplicate-safe")
207
+ )
208
+ val exactSubscribe = radio.substring(
209
+ radio.indexOf("private fun setNotifyTarget("),
210
+ radio.indexOf("private fun rollbackNotifyRegistration")
211
+ )
212
+ val descriptorCallback = radio.substring(
213
+ radio.indexOf("override fun onDescriptorWrite"),
214
+ radio.indexOf("@Deprecated(\"Deprecated in Java\")")
215
+ )
216
+
217
+ assertTrue(pendingDrain.contains("gattStatus: Int?"))
218
+ assertTrue(pendingDrain.contains("classifyAndroidGattOperationFailure(\"cccd-write\", gattStatus)"))
219
+ assertTrue(pendingDrain.contains("if (key.startsWith(\"cccd:\")) failCccd else failUnit"))
220
+ assertTrue(pendingDrain.contains("if (entry.value.subscriptionEnabled != null) failCccd else failUnit"))
221
+ assertTrue(nonExactSubscribe.contains("pendingDesc[key]"))
222
+ assertTrue(exactSubscribe.contains("exactCccdPending.putIfAbsent(cccd, pending)"))
223
+ assertTrue(descriptorCallback.contains("classifyAndroidGattOperationFailure"))
224
+ assertTrue(
225
+ disconnectCallback.contains(
226
+ "failPendingForDevice(key, \"disconnected status=\$status\", status)"
227
+ )
228
+ )
229
+ }
230
+
231
+ @Test
232
+ fun cccdRollbackFailureDoesNotReplaceThePrimaryTypedGattFailure() {
233
+ val radio = readAndroidSource(
234
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt"
235
+ )
236
+ val completion = radio.substring(
237
+ radio.indexOf("private fun completeExactUnit("),
238
+ radio.indexOf("private fun rollbackNotifyRegistration")
239
+ )
240
+
241
+ assertTrue(completion.contains("androidGattTerminalResult(result, rollbackFailure)"))
242
+ assertTrue(completion.contains("registerRetryableCleanup(rollbackFailure.operation)"))
243
+ assertTrue(completion.contains("reportCleanupFailure(rollbackFailure)"))
244
+ }
245
+
246
+ @Test
247
+ fun cccdRollbackFailurePreservesTypedPrimaryAndKeepsSuccessfulPrimaryCleanupFailureBehavior() {
248
+ val primary = classifyAndroidGattOperationFailure("cccd-write", 19)
249
+ val rollbackFailure = OwnedRadioTeardownFailure(
250
+ "cccdRollback:AA:BB",
251
+ IllegalStateException("link is gone")
252
+ )
253
+
254
+ val typedFailure = androidGattTerminalResult(Result.failure<Unit>(primary), rollbackFailure)
255
+ val typedError = typedFailure.exceptionOrNull()
256
+ assertTrue(typedError === primary)
257
+ assertTrue(typedError is AndroidGattOperationFailure)
258
+ if (typedError !is AndroidGattOperationFailure) throw AssertionError("typed primary was flattened")
259
+ assertTrue(typedError.isLinkLoss)
260
+
261
+ val callbackFailure = classifyAndroidGattOperationFailure("cccd-write", 133)
262
+ val callbackFailureWithGenericCleanupError = androidGattTerminalResult(
263
+ Result.failure<Unit>(callbackFailure),
264
+ rollbackFailure
265
+ )
266
+ assertTrue(callbackFailureWithGenericCleanupError.exceptionOrNull() === callbackFailure)
267
+
268
+ val rejectedRegistrationRollback = OwnedRadioTeardownFailure(
269
+ "cccdRollback:AA:BB",
270
+ AndroidNotificationRollbackRejected()
271
+ )
272
+ val linkLossAfterCallbackFailure = androidGattTerminalResult(
273
+ Result.failure<Unit>(callbackFailure),
274
+ rejectedRegistrationRollback
275
+ )
276
+ val callbackError = linkLossAfterCallbackFailure.exceptionOrNull()
277
+ assertTrue(callbackError is AndroidGattOperationFailure)
278
+ if (callbackError !is AndroidGattOperationFailure) {
279
+ throw AssertionError("CCCD callback failure plus rejected rollback was not classified")
280
+ }
281
+ assertTrue(callbackError.isLinkLoss)
282
+ assertEquals(133, callbackError.gattStatus)
283
+
284
+ val descriptorFailure = classifyAndroidGattOperationFailure("descriptor-write", 133)
285
+ assertTrue(
286
+ androidGattTerminalResult(
287
+ Result.failure<Unit>(descriptorFailure),
288
+ rejectedRegistrationRollback
289
+ ).exceptionOrNull() === descriptorFailure
290
+ )
291
+
292
+ val successfulPrimary = androidGattTerminalResult(Result.success(Unit), rollbackFailure)
293
+ assertTrue(successfulPrimary.isFailure)
294
+ assertTrue(successfulPrimary.exceptionOrNull() is IllegalStateException)
295
+ assertTrue(successfulPrimary.exceptionOrNull()?.message?.contains("CCCD rollback failed") == true)
296
+
297
+ val ordinaryPrimary = IllegalStateException("ordinary CCCD failure")
298
+ val ordinaryFailure = androidGattTerminalResult(Result.failure<Unit>(ordinaryPrimary), rollbackFailure)
299
+ assertTrue(ordinaryFailure.exceptionOrNull() === ordinaryPrimary)
300
+
301
+ val synchronousSubmission = AndroidCccdSubmissionFailure(133)
302
+ assertEquals(133, synchronousSubmission.platformStatus)
303
+ val linkLossAfterSubmission = androidGattTerminalResult(
304
+ Result.failure<Unit>(synchronousSubmission),
305
+ rejectedRegistrationRollback
306
+ )
307
+ val linkLossError = linkLossAfterSubmission.exceptionOrNull()
308
+ assertTrue(linkLossError is AndroidGattOperationFailure)
309
+ if (linkLossError !is AndroidGattOperationFailure) throw AssertionError("submission failure was not classified")
310
+ assertTrue(linkLossError.isLinkLoss)
311
+ assertTrue(
312
+ androidGattTerminalResult(Result.failure(synchronousSubmission), null).exceptionOrNull() ===
313
+ synchronousSubmission
314
+ )
315
+ val legacySubmission = AndroidCccdSubmissionFailure(null)
316
+ assertNull(legacySubmission.platformStatus)
317
+ }
318
+
319
+ @Test
320
+ fun exactCccdSubmissionRejectionIsMarkedBeforeRollbackClassification() {
321
+ val radio = readAndroidSource(
322
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt"
323
+ )
324
+ val exactSubscribe = radio.substring(
325
+ radio.indexOf("private fun setNotifyTarget("),
326
+ radio.indexOf("private fun rollbackNotifyRegistration")
327
+ )
328
+ val completion = radio.substring(
329
+ radio.indexOf("private fun completeExactUnit("),
330
+ radio.indexOf("private fun rollbackNotifyRegistration")
331
+ )
332
+
333
+ assertTrue(exactSubscribe.contains("AndroidCccdSubmissionFailure"))
334
+ assertTrue(exactSubscribe.contains("AndroidCccdSubmissionFailure(status)"))
335
+ assertTrue(exactSubscribe.contains("AndroidCccdSubmissionFailure(null)"))
336
+ assertTrue(completion.contains("androidGattTerminalResult(result, rollbackFailure)"))
337
+ }
338
+
339
+ @Test
340
+ fun publicSubscribeUsesExactNotificationPathWhileLegacyPrivatePathStaysOrdinary() {
341
+ val dispatcher = readAndroidSource(
342
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcher.kt"
343
+ )
344
+ val subscribe = dispatcher.substring(
345
+ dispatcher.indexOf("private fun subscribe(command: ProtocolWireRecord, enable: Boolean)"),
346
+ dispatcher.indexOf("private fun destroy(command: ProtocolWireRecord)")
347
+ )
348
+ assertTrue(subscribe.contains("radio.setNotifyExact("))
349
+ assertFalse(subscribe.contains("radio.setNotify("))
350
+
351
+ val radio = readAndroidSource(
352
+ "android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt"
353
+ )
354
+ val legacy = radio.substring(
355
+ radio.indexOf("private fun setNotify("),
356
+ radio.indexOf("/** Enables or disables an exact duplicate-safe")
357
+ )
358
+ assertTrue(legacy.contains("IllegalStateException(\"writeDescriptor failed to start status=\$status\")"))
359
+ assertTrue(legacy.contains("IllegalStateException(\"writeDescriptor failed to start\")"))
360
+ assertFalse(legacy.contains("AndroidCccdSubmissionFailure"))
361
+ }
362
+
103
363
  @Test
104
364
  fun bondedPeerAdapterReadinessReturnsTypedFailuresInsteadOfAnEmptySuccess() {
105
365
  assertTrue(
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.20`. 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.22`. 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.20` source. Those options match
10
+ Use the v2 plugin options in this `4.0.22` 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.20`; the npm registry and release
17
+ This source targets `unified-ble-manager@4.0.22`; 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.20`; verify the published version in the npm registry.
7
+ This source targets `4.0.22`; 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.20`, install that exact version and keep it in
41
+ After the npm registry lists `4.0.22`, 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.20
45
+ pnpm add unified-ble-manager@4.0.22
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.20`. 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.22`. 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.20`. 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.22`. 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.20`
9
+ `unified-ble-manager@4.0.22`
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.20';
10
+ const UNIFIED_BLE_IMPLEMENTATION_VERSION = exports.UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.22';
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.20';
6
+ export const UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.22';
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.20";
7
+ export declare const BLUEZ_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
4
+ export declare const COREBLUETOOTH_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
12
+ export declare const REACT_NATIVE_ANDROID_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
6
+ export declare const REACT_NATIVE_APPLE_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
6
+ export declare const WINRT_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
2
+ export declare const UNIFIED_BLE_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
7
+ export declare const BLUEZ_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
4
+ export declare const COREBLUETOOTH_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
12
+ export declare const REACT_NATIVE_ANDROID_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
6
+ export declare const REACT_NATIVE_APPLE_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
6
+ export declare const WINRT_IMPLEMENTATION_VERSION = "4.0.22";
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.20";
2
+ export declare const UNIFIED_BLE_IMPLEMENTATION_VERSION = "4.0.22";
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.20",
3
+ "package": "unified-ble-manager@4.0.22",
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": "dcc945c4b143c85aec7e32d704e8ee82f6a55037a1088a932abe628a4d9ad8c6"
13
+ "sha256": "32005cc887bee75665ad0dd1ebf8ea39affaab593d41d53b65c7e05810185abd"
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": "a200020a57dd62ec81f25ea2b9e8e8746717f29fd169d480bf0cc481fc92abff"
22
+ "sha256": "b57f1f75ea39424b7525560408f84f0fac67467073fd05e0606c44383a94bcf5"
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": "c7eb7785fc9f9b03b09bd38af160e100ac8577f973683a3c0080fba5700fcc90"
31
+ "sha256": "3dbd53397b3386dff78d77942ba0f83329c4126cec1fa4d8ff6c910daddef9ce"
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": "414c452a434a0a32789fd447dba598bf6212ecbbfbf02bd482461d978accdd51"
40
+ "sha256": "0b37b8bdbd55ecce5edbd7b30615181aa8891951799a86c33557a1f7011563cb"
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.20",
3
+ "version": "4.0.22",
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.20'
4
+ export const UNIFIED_BLE_IMPLEMENTATION_VERSION = '4.0.22'