unified-ble-manager 4.0.20 → 4.0.23
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 +51 -0
- package/MIGRATION_4.0.md +1 -1
- package/README.md +1 -1
- package/RELEASE.md +40 -2
- package/SBOM.cdx.json +4 -4
- package/THIRD_PARTY_LICENSES.json +1 -1
- package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt +146 -25
- package/android/src/test/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcherLifecycleTest.kt +260 -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,57 @@ All notable changes to `unified-ble-manager` are documented here.
|
|
|
6
6
|
|
|
7
7
|
No changes yet.
|
|
8
8
|
|
|
9
|
+
## [4.0.23] - 2026-09-05
|
|
10
|
+
|
|
11
|
+
### Fixes
|
|
12
|
+
|
|
13
|
+
- Share one adapter-state polling owner across IPC state watchers and readiness waits, sampling at
|
|
14
|
+
a 500 ms control-plane cadence. The previous 25 ms polling loop could fill
|
|
15
|
+
Tauri's bounded native correlation window and prevent scan cleanup or other
|
|
16
|
+
operations. Watchers retain independent cancellation and bounded streams;
|
|
17
|
+
stopping the last watcher releases polling, and stale reads cannot affect a
|
|
18
|
+
replacement watch owner. Native replay protection remains unchanged.
|
|
19
|
+
- Honor readiness deadlines shorter than the polling interval and abort a
|
|
20
|
+
pending readiness delay immediately without issuing an extra native route.
|
|
21
|
+
- Cancel event-driven readiness waits promptly in the shared public wrapper,
|
|
22
|
+
preserving `operation.aborted` even when the underlying stream closes first.
|
|
23
|
+
- Bound and retain pending backend state-watch acquisitions. Aborted or timed-out
|
|
24
|
+
readiness calls no longer wait indefinitely for Web availability probes; late
|
|
25
|
+
resources are cleaned up and failed cleanup remains retryable during teardown.
|
|
26
|
+
|
|
27
|
+
## [4.0.22] - 2026-09-04
|
|
28
|
+
|
|
29
|
+
### Fixes
|
|
30
|
+
|
|
31
|
+
- Arbitrate an Android asynchronous CCCD failure against an already-in-flight
|
|
32
|
+
disconnect callback for the same GATT generation. Android can deliver status
|
|
33
|
+
133 from `onDescriptorWrite`, accept local notification rollback, and only
|
|
34
|
+
then deliver the authoritative status-19 disconnect; exact subscriptions now
|
|
35
|
+
retain that provisional failure for a bounded 250 ms evidence window so the
|
|
36
|
+
command reports typed `connection.lost` without hiding genuine CCCD errors.
|
|
37
|
+
|
|
38
|
+
## [4.0.21] - 2026-09-04 (unpublished)
|
|
39
|
+
|
|
40
|
+
Publication was cancelled before the npm version check after physical Android
|
|
41
|
+
hardware reproduced a later callback ordering that required the 4.0.22 fix.
|
|
42
|
+
|
|
43
|
+
### Fixes
|
|
44
|
+
|
|
45
|
+
- Preserve Android GATT status 19 when a peer disconnects while an exact or
|
|
46
|
+
non-exact CCCD subscription is awaiting `onDescriptorWrite`. The pending
|
|
47
|
+
subscription now reports typed `connection.lost` instead of generic
|
|
48
|
+
`platform.failure`; asynchronous CCCD status 133 remains an ordinary failure
|
|
49
|
+
when local-registration rollback succeeds.
|
|
50
|
+
- Preserve that typed primary link-loss result when exact CCCD rollback also
|
|
51
|
+
fails; the cleanup failure remains separately reported and retryable.
|
|
52
|
+
- Classify an exact synchronous CCCD descriptor-submission rejection as link
|
|
53
|
+
loss only when the subsequent local-registration rollback is also rejected;
|
|
54
|
+
genuine subscription rejections remain ordinary failures.
|
|
55
|
+
- Apply the same two-signal rule when Android accepts descriptor submission but
|
|
56
|
+
its asynchronous CCCD callback fails before the connection-state callback:
|
|
57
|
+
a rejected rollback proves the link is gone, while a successful rollback
|
|
58
|
+
preserves the original platform failure and GATT status.
|
|
59
|
+
|
|
9
60
|
## [4.0.20] - 2026-09-03
|
|
10
61
|
|
|
11
62
|
### 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.23`. The `4.0.0` release is a new package and a new contract. It is **not a source-compatible rename**. There is no `new BleManager()` facade, no Base64 characteristic values, and no public transaction IDs.
|
|
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.23`. 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.
|
|
84
|
+
once tagged. Stable `4.0.0` through `4.0.20` are immutable. The unpublished
|
|
85
|
+
`v4.0.21` tag is also immutable after its cancelled workflow. `4.0.22` is immutable published history. `4.0.23` is the
|
|
86
|
+
current train head.
|
|
85
87
|
|
|
86
88
|
```sh
|
|
87
89
|
release_candidate=4.0.0-rc.N
|
|
@@ -109,6 +111,42 @@ 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.23
|
|
115
|
+
|
|
116
|
+
Release `v4.0.23` only from the exact current `main` commit after its canonical
|
|
117
|
+
CI succeeds. Verify `package.json` is `4.0.23`, the worktree is clean, and
|
|
118
|
+
release-note extraction finds `## [4.0.23]`. Push a new annotated `v4.0.23`
|
|
119
|
+
tag; never move the immutable `v4.0.22` tag. Follow the required local validation,
|
|
120
|
+
publish workflow, and registry verification below.
|
|
121
|
+
|
|
122
|
+
## Releasing 4.0.22
|
|
123
|
+
|
|
124
|
+
The `v4.0.22` tag must identify the exact current `main` commit after canonical
|
|
125
|
+
CI passes. Do not tag the release branch directly.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
git fetch origin --tags
|
|
129
|
+
git checkout main
|
|
130
|
+
git pull --ff-only origin main
|
|
131
|
+
|
|
132
|
+
test "$(git branch --show-current)" = "main"
|
|
133
|
+
test "$(node -p "require('./package.json').version")" = "4.0.22"
|
|
134
|
+
git diff --exit-code
|
|
135
|
+
git diff --cached --exit-code
|
|
136
|
+
|
|
137
|
+
git tag -a v4.0.22 -m "v4.0.22"
|
|
138
|
+
git push origin v4.0.22
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Before tagging, confirm release-note extraction finds `## [4.0.22]`.
|
|
142
|
+
|
|
143
|
+
## Unpublished 4.0.21 tag
|
|
144
|
+
|
|
145
|
+
The immutable `v4.0.21` tag triggered workflow `33862393779`, which was
|
|
146
|
+
cancelled during native prebuilds before the npm version check or publication
|
|
147
|
+
after physical Android hardware exposed a remaining CCCD callback-order race.
|
|
148
|
+
It must not be recreated, moved, or published manually.
|
|
149
|
+
|
|
112
150
|
## Releasing 4.0.20
|
|
113
151
|
|
|
114
152
|
The `v4.0.20` tag must identify the exact current `main` commit after canonical
|
|
@@ -445,7 +483,7 @@ a green publish job and a package a consumer can actually install are not the
|
|
|
445
483
|
same claim.
|
|
446
484
|
|
|
447
485
|
```sh
|
|
448
|
-
version=4.0.
|
|
486
|
+
version=4.0.23
|
|
449
487
|
|
|
450
488
|
npm view "unified-ble-manager@$version" version
|
|
451
489
|
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.23",
|
|
10
10
|
"name": "unified-ble-manager",
|
|
11
|
-
"version": "4.0.
|
|
11
|
+
"version": "4.0.23",
|
|
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.23"
|
|
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.23",
|
|
541
541
|
"dependsOn": [
|
|
542
542
|
"pkg:npm/%40babel/runtime@7.29.7",
|
|
543
543
|
"pkg:npm/node-addon-api@8.9.0",
|
package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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 ->
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2639
|
+
if (exactCccdPending.remove(descriptor, pending)) {
|
|
2640
|
+
completeExactUnit(pending, Result.success(Unit))
|
|
2641
|
+
}
|
|
2524
2642
|
} else {
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
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
|
}
|