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.
- package/CHANGELOG.md +33 -0
- package/MIGRATION_4.0.md +1 -1
- package/README.md +1 -1
- package/RELEASE.md +19 -3
- package/SBOM.cdx.json +4 -4
- package/THIRD_PARTY_LICENSES.json +1 -1
- package/android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcher.kt +5 -3
- package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt +196 -60
- package/android/src/test/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcherLifecycleTest.kt +202 -0
- package/docs/ELECTRON.md +1 -1
- package/docs/EXPO_PLUGIN.md +1 -1
- package/docs/GAPS.4.0.md +1 -1
- package/docs/GETTING_STARTED.md +3 -3
- package/docs/NODE.md +1 -1
- package/docs/WEB.md +1 -1
- package/docs/generated/PLATFORM_SUPPORT.md +1 -1
- package/lib/commonjs/core/unified-ble-core.js +49 -4
- package/lib/commonjs/core/unified-ble-core.js.map +1 -1
- package/lib/commonjs/implementation-version.js +1 -1
- package/lib/commonjs/ipc/public-manager.js +206 -103
- package/lib/commonjs/ipc/public-manager.js.map +1 -1
- package/lib/commonjs/public/ble-manager.js +39 -6
- package/lib/commonjs/public/ble-manager.js.map +1 -1
- package/lib/module/core/unified-ble-core.js +49 -4
- package/lib/module/core/unified-ble-core.js.map +1 -1
- package/lib/module/implementation-version.js +1 -1
- package/lib/module/ipc/public-manager.js +206 -103
- package/lib/module/ipc/public-manager.js.map +1 -1
- package/lib/module/public/ble-manager.js +40 -7
- package/lib/module/public/ble-manager.js.map +1 -1
- package/lib/typescript/commonjs/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
- package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
- package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
- package/lib/typescript/commonjs/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
- package/lib/typescript/commonjs/src/backends/winrt/winrt-provider.d.ts +1 -1
- package/lib/typescript/commonjs/src/core/unified-ble-core.d.ts +2 -0
- package/lib/typescript/commonjs/src/core/unified-ble-core.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/implementation-version.d.ts +1 -1
- package/lib/typescript/commonjs/src/ipc/public-manager.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/public/ble-manager.d.ts.map +1 -1
- package/lib/typescript/module/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
- package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
- package/lib/typescript/module/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
- package/lib/typescript/module/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
- package/lib/typescript/module/src/backends/winrt/winrt-provider.d.ts +1 -1
- package/lib/typescript/module/src/core/unified-ble-core.d.ts +2 -0
- package/lib/typescript/module/src/core/unified-ble-core.d.ts.map +1 -1
- package/lib/typescript/module/src/implementation-version.d.ts +1 -1
- package/lib/typescript/module/src/ipc/public-manager.d.ts.map +1 -1
- package/lib/typescript/module/src/public/ble-manager.d.ts.map +1 -1
- package/native/PREBUILDS.json +5 -5
- package/native/electron/corebluetooth/prebuilds/darwin-arm64/unified_ble_corebluetooth.node +0 -0
- package/native/electron/corebluetooth/prebuilds/darwin-x64/unified_ble_corebluetooth.node +0 -0
- package/native/electron/winrt/prebuilds/win32-arm64/unified_ble_winrt.node +0 -0
- package/native/electron/winrt/prebuilds/win32-x64/unified_ble_winrt.node +0 -0
- package/package.json +1 -1
- package/src/core/unified-ble-core.ts +57 -4
- package/src/implementation-version.ts +1 -1
- package/src/ipc/public-manager.ts +248 -114
- 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.
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
9
|
+
"bom-ref": "pkg:npm/unified-ble-manager@4.0.24",
|
|
10
10
|
"name": "unified-ble-manager",
|
|
11
|
-
"version": "4.0.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -409,7 +409,9 @@ class UnifiedBleProtocolAndroidDispatcher(
|
|
|
409
409
|
) { result ->
|
|
410
410
|
result.fold(
|
|
411
411
|
onSuccess = { emitSuccess(command, "write") },
|
|
412
|
-
onFailure = { error ->
|
|
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
|
|
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),
|
package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt
CHANGED
|
@@ -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
|
|
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
|
|
139
|
-
* authoritative
|
|
140
|
-
* Keep only that provisional result pending long enough for
|
|
141
|
-
* already in flight to claim it. Descriptor writes and
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
227
|
+
val deferred = DeferredFailure(failure)
|
|
228
|
+
if (provisionalFailures.putIfAbsent(key, deferred) != null) return
|
|
159
229
|
val fallback: () -> Unit = {
|
|
160
|
-
|
|
161
|
-
if (retained != null) onFallback(key, retained)
|
|
230
|
+
if (provisionalFailures.remove(key, deferred)) onFallback(key, deferred.failure)
|
|
162
231
|
}
|
|
163
|
-
if (!schedule(
|
|
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
|
-
/**
|
|
365
|
-
private val
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
2244
|
-
// that exact Android status on pending CCCD work so the
|
|
2245
|
-
// reports connectionLost instead of flattening the race
|
|
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 ->
|
|
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
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2832
|
-
|
|
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
|
-
|
|
2965
|
+
if (exactWritePending.remove(characteristic, pending)) {
|
|
2966
|
+
removeAndroidWritePayloadIfSame(exactWriteValues, characteristic, observedPayload)
|
|
2967
|
+
completeExactByte(pending, Result.success(observedPayload?.copyOf()))
|
|
2968
|
+
}
|
|
2838
2969
|
} else {
|
|
2839
|
-
|
|
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
|
-
|
|
2983
|
+
if (writeTerminalArbiter.isPending(key)) return
|
|
2846
2984
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
|
2847
|
-
//
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
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
|
-
|
|
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
|
|