unified-ble-manager 4.0.22 → 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 (60) 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 +19 -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/core/unified-ble-core.js +49 -4
  18. package/lib/commonjs/core/unified-ble-core.js.map +1 -1
  19. package/lib/commonjs/implementation-version.js +1 -1
  20. package/lib/commonjs/ipc/public-manager.js +206 -103
  21. package/lib/commonjs/ipc/public-manager.js.map +1 -1
  22. package/lib/commonjs/public/ble-manager.js +39 -6
  23. package/lib/commonjs/public/ble-manager.js.map +1 -1
  24. package/lib/module/core/unified-ble-core.js +49 -4
  25. package/lib/module/core/unified-ble-core.js.map +1 -1
  26. package/lib/module/implementation-version.js +1 -1
  27. package/lib/module/ipc/public-manager.js +206 -103
  28. package/lib/module/ipc/public-manager.js.map +1 -1
  29. package/lib/module/public/ble-manager.js +40 -7
  30. package/lib/module/public/ble-manager.js.map +1 -1
  31. package/lib/typescript/commonjs/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  32. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  33. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  34. package/lib/typescript/commonjs/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  35. package/lib/typescript/commonjs/src/backends/winrt/winrt-provider.d.ts +1 -1
  36. package/lib/typescript/commonjs/src/core/unified-ble-core.d.ts +2 -0
  37. package/lib/typescript/commonjs/src/core/unified-ble-core.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/src/implementation-version.d.ts +1 -1
  39. package/lib/typescript/commonjs/src/ipc/public-manager.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/src/public/ble-manager.d.ts.map +1 -1
  41. package/lib/typescript/module/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  42. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  43. package/lib/typescript/module/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  44. package/lib/typescript/module/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  45. package/lib/typescript/module/src/backends/winrt/winrt-provider.d.ts +1 -1
  46. package/lib/typescript/module/src/core/unified-ble-core.d.ts +2 -0
  47. package/lib/typescript/module/src/core/unified-ble-core.d.ts.map +1 -1
  48. package/lib/typescript/module/src/implementation-version.d.ts +1 -1
  49. package/lib/typescript/module/src/ipc/public-manager.d.ts.map +1 -1
  50. package/lib/typescript/module/src/public/ble-manager.d.ts.map +1 -1
  51. package/native/PREBUILDS.json +5 -5
  52. package/native/electron/corebluetooth/prebuilds/darwin-arm64/unified_ble_corebluetooth.node +0 -0
  53. package/native/electron/corebluetooth/prebuilds/darwin-x64/unified_ble_corebluetooth.node +0 -0
  54. package/native/electron/winrt/prebuilds/win32-arm64/unified_ble_winrt.node +0 -0
  55. package/native/electron/winrt/prebuilds/win32-x64/unified_ble_winrt.node +0 -0
  56. package/package.json +1 -1
  57. package/src/core/unified-ble-core.ts +57 -4
  58. package/src/implementation-version.ts +1 -1
  59. package/src/ipc/public-manager.ts +248 -114
  60. package/src/public/ble-manager.ts +54 -7
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.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
+
24
+ ## [4.0.23] - 2026-09-05
25
+
26
+ ### Fixes
27
+
28
+ - Share one adapter-state polling owner across IPC state watchers and readiness waits, sampling at
29
+ a 500 ms control-plane cadence. The previous 25 ms polling loop could fill
30
+ Tauri's bounded native correlation window and prevent scan cleanup or other
31
+ operations. Watchers retain independent cancellation and bounded streams;
32
+ stopping the last watcher releases polling, and stale reads cannot affect a
33
+ replacement watch owner. Native replay protection remains unchanged.
34
+ - Honor readiness deadlines shorter than the polling interval and abort a
35
+ pending readiness delay immediately without issuing an extra native route.
36
+ - Cancel event-driven readiness waits promptly in the shared public wrapper,
37
+ preserving `operation.aborted` even when the underlying stream closes first.
38
+ - Bound and retain pending backend state-watch acquisitions. Aborted or timed-out
39
+ readiness calls no longer wait indefinitely for Web availability probes; late
40
+ resources are cleaned up and failed cleanup remains retryable during teardown.
41
+
9
42
  ## [4.0.22] - 2026-09-04
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.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.
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.22`. 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 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,22 @@ 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
+
122
+ ## Releasing 4.0.23
123
+
124
+ Release `v4.0.23` only from the exact current `main` commit after its canonical
125
+ CI succeeds. Verify `package.json` is `4.0.23`, the worktree is clean, and
126
+ release-note extraction finds `## [4.0.23]`. Push a new annotated `v4.0.23`
127
+ tag; never move the immutable `v4.0.22` tag. Follow the required local validation,
128
+ publish workflow, and registry verification below.
129
+
114
130
  ## Releasing 4.0.22
115
131
 
116
132
  The `v4.0.22` tag must identify the exact current `main` commit after canonical
@@ -475,7 +491,7 @@ a green publish job and a package a consumer can actually install are not the
475
491
  same claim.
476
492
 
477
493
  ```sh
478
- version=4.0.22
494
+ version=4.0.24
479
495
 
480
496
  npm view "unified-ble-manager@$version" version
481
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.22",
9
+ "bom-ref": "pkg:npm/unified-ble-manager@4.0.24",
10
10
  "name": "unified-ble-manager",
11
- "version": "4.0.22",
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.22"
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.22",
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.22"
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