unified-ble-manager 4.0.13 → 4.0.16

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 (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/MIGRATION_4.0.md +1 -1
  3. package/README.md +1 -1
  4. package/RELEASE.md +42 -8
  5. package/SBOM.cdx.json +5 -5
  6. package/THIRD_PARTY_LICENSES.json +2 -2
  7. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcher.kt +79 -7
  8. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt +67 -3
  9. package/android/src/test/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcherLifecycleTest.kt +170 -1
  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/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.js +3 -0
  18. package/lib/commonjs/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.js.map +1 -1
  19. package/lib/commonjs/backends/corebluetooth/corebluetooth-backend.js +41 -6
  20. package/lib/commonjs/backends/corebluetooth/corebluetooth-backend.js.map +1 -1
  21. package/lib/commonjs/backends/corebluetooth/corebluetooth-late-connect-cleanup.js +1 -1
  22. package/lib/commonjs/backends/corebluetooth/corebluetooth-late-connect-cleanup.js.map +1 -1
  23. package/lib/commonjs/backends/corebluetooth/corebluetooth-operation-lifecycle.js +17 -3
  24. package/lib/commonjs/backends/corebluetooth/corebluetooth-operation-lifecycle.js.map +1 -1
  25. package/lib/commonjs/backends/winrt/winrt-backend.js +52 -10
  26. package/lib/commonjs/backends/winrt/winrt-backend.js.map +1 -1
  27. package/lib/commonjs/backends/winrt/winrt-handles.js +1 -1
  28. package/lib/commonjs/backends/winrt/winrt-handles.js.map +1 -1
  29. package/lib/commonjs/implementation-version.js +1 -1
  30. package/lib/commonjs/native-protocol/rn-android-boundary.js +55 -3
  31. package/lib/commonjs/native-protocol/rn-android-boundary.js.map +1 -1
  32. package/lib/module/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.js +3 -0
  33. package/lib/module/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.js.map +1 -1
  34. package/lib/module/backends/corebluetooth/corebluetooth-backend.js +41 -6
  35. package/lib/module/backends/corebluetooth/corebluetooth-backend.js.map +1 -1
  36. package/lib/module/backends/corebluetooth/corebluetooth-late-connect-cleanup.js +1 -1
  37. package/lib/module/backends/corebluetooth/corebluetooth-late-connect-cleanup.js.map +1 -1
  38. package/lib/module/backends/corebluetooth/corebluetooth-operation-lifecycle.js +17 -3
  39. package/lib/module/backends/corebluetooth/corebluetooth-operation-lifecycle.js.map +1 -1
  40. package/lib/module/backends/winrt/winrt-backend.js +52 -10
  41. package/lib/module/backends/winrt/winrt-backend.js.map +1 -1
  42. package/lib/module/backends/winrt/winrt-handles.js +1 -1
  43. package/lib/module/backends/winrt/winrt-handles.js.map +1 -1
  44. package/lib/module/implementation-version.js +1 -1
  45. package/lib/module/native-protocol/rn-android-boundary.js +55 -3
  46. package/lib/module/native-protocol/rn-android-boundary.js.map +1 -1
  47. package/lib/typescript/commonjs/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  48. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.d.ts +1 -0
  49. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-backend.d.ts +2 -0
  51. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-backend.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  53. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-operation-lifecycle.d.ts +1 -1
  54. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-operation-lifecycle.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  56. package/lib/typescript/commonjs/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  57. package/lib/typescript/commonjs/src/backends/winrt/winrt-backend.d.ts +3 -0
  58. package/lib/typescript/commonjs/src/backends/winrt/winrt-backend.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/src/backends/winrt/winrt-provider.d.ts +1 -1
  60. package/lib/typescript/commonjs/src/implementation-version.d.ts +1 -1
  61. package/lib/typescript/commonjs/src/native-protocol/rn-android-boundary.d.ts +8 -0
  62. package/lib/typescript/commonjs/src/native-protocol/rn-android-boundary.d.ts.map +1 -1
  63. package/lib/typescript/module/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  64. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.d.ts +1 -0
  65. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.d.ts.map +1 -1
  66. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-backend.d.ts +2 -0
  67. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-backend.d.ts.map +1 -1
  68. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  69. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-operation-lifecycle.d.ts +1 -1
  70. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-operation-lifecycle.d.ts.map +1 -1
  71. package/lib/typescript/module/src/backends/reactnative/react-native-android-provider.d.ts +1 -1
  72. package/lib/typescript/module/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  73. package/lib/typescript/module/src/backends/winrt/winrt-backend.d.ts +3 -0
  74. package/lib/typescript/module/src/backends/winrt/winrt-backend.d.ts.map +1 -1
  75. package/lib/typescript/module/src/backends/winrt/winrt-provider.d.ts +1 -1
  76. package/lib/typescript/module/src/implementation-version.d.ts +1 -1
  77. package/lib/typescript/module/src/native-protocol/rn-android-boundary.d.ts +8 -0
  78. package/lib/typescript/module/src/native-protocol/rn-android-boundary.d.ts.map +1 -1
  79. package/native/PREBUILDS.json +5 -5
  80. package/native/electron/corebluetooth/prebuilds/darwin-arm64/unified_ble_corebluetooth.node +0 -0
  81. package/native/electron/corebluetooth/prebuilds/darwin-x64/unified_ble_corebluetooth.node +0 -0
  82. package/native/electron/winrt/prebuilds/win32-arm64/unified_ble_winrt.node +0 -0
  83. package/native/electron/winrt/prebuilds/win32-x64/unified_ble_winrt.node +0 -0
  84. package/package.json +1 -1
  85. package/src/backends/corebluetooth/corebluetooth-adapter-loss-cleanup.ts +4 -0
  86. package/src/backends/corebluetooth/corebluetooth-backend.ts +45 -6
  87. package/src/backends/corebluetooth/corebluetooth-late-connect-cleanup.ts +1 -1
  88. package/src/backends/corebluetooth/corebluetooth-operation-lifecycle.ts +18 -3
  89. package/src/backends/winrt/winrt-backend.ts +57 -10
  90. package/src/backends/winrt/winrt-handles.ts +1 -1
  91. package/src/implementation-version.ts +1 -1
  92. package/src/native-protocol/rn-android-boundary.ts +70 -3
package/CHANGELOG.md CHANGED
@@ -6,6 +6,47 @@ All notable changes to `unified-ble-manager` are documented here.
6
6
 
7
7
  No changes yet.
8
8
 
9
+ ## [4.0.16] - 2026-09-03
10
+
11
+ ### Fixes
12
+
13
+ - Normalize an Android CCCD operation that terminates with GATT status 19 as
14
+ `connection.lost` even when it arrives before the native `connectionLost`
15
+ event. Other CCCD failures remain `platform.failure` and retain their native
16
+ GATT status.
17
+ - Cancel a dispatched React Native Android `when-available` connection when
18
+ its caller aborts, releasing the native pending connection so a later
19
+ same-peer retry is admitted without affecting distinct-peer connects.
20
+
21
+ ## [4.0.15] - 2026-09-02
22
+
23
+ ### Fixes
24
+
25
+ - Silence exactly one expected late React Native Android terminal when a peer
26
+ is lost while an operation such as GATT discovery is still settling. The
27
+ boundary records only operations it explicitly rejected for that peer,
28
+ bounds and lifecycle-scopes those records, and continues to report unknown
29
+ or duplicate terminals as protocol diagnostics.
30
+
31
+ ## [4.0.14] - 2026-09-02
32
+
33
+ ### Fixes
34
+
35
+ - Release Android GATT ownership immediately when Bluetooth powers off or
36
+ resets instead of waiting for a `STATE_DISCONNECTED` callback that Android
37
+ may never deliver. Failed native closes remain retained and retry when the
38
+ adapter returns, protocol routes are invalidated before adapter-state
39
+ publication, and an ordinary disconnect timeout now also publishes the
40
+ missing terminal state before cleanup. A cleanup unsubscribe after adapter
41
+ loss is idempotent when native ownership is already gone.
42
+ - Terminalize adapter-loss connections exactly once with the typed `adapter`
43
+ reason when a native CoreBluetooth-style or WinRT disconnect completes
44
+ without a platform loss callback. Delayed native callbacks are ignored, and
45
+ WinRT connection paths retain the attachment that created them.
46
+ - Treat an operation that is still settling after adapter loss as expected
47
+ quarantine rather than a cleanup error. Cleanup still waits and retries, but
48
+ only an actual native release failure produces an error diagnostic.
49
+
9
50
  ## [4.0.13] - 2026-09-01
10
51
 
11
52
  ### 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.13`. 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.16`. 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.13`. Install the exact version shown in the npm
21
+ This source tree is versioned `4.0.16`. 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,7 @@ 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.12` are immutable. `4.0.13` is the current train head.
84
+ once tagged. Stable `4.0.0` through `4.0.15` are immutable. `4.0.16` is the current train head.
85
85
 
86
86
  ```sh
87
87
  release_candidate=4.0.0-rc.N
@@ -133,9 +133,30 @@ The `v4.0.3` tag is immutable published history. Do not recreate or move it.
133
133
  git tag -a v4.0.3 -m "v4.0.3"
134
134
  ```
135
135
 
136
- ## Releasing 4.0.13
136
+ ## Releasing 4.0.16
137
+
138
+ The `v4.0.16` tag must identify the exact current `main` commit after canonical
139
+ CI passes. Do not tag the release branch directly.
140
+
141
+ ```sh
142
+ git fetch origin --tags
143
+ git checkout main
144
+ git pull --ff-only origin main
145
+
146
+ test "$(git branch --show-current)" = "main"
147
+ test "$(node -p "require('./package.json').version")" = "4.0.16"
148
+ git diff --exit-code
149
+ git diff --cached --exit-code
150
+
151
+ git tag -a v4.0.16 -m "v4.0.16"
152
+ git push origin v4.0.16
153
+ ```
137
154
 
138
- The `v4.0.13` tag must identify the exact current `main` commit after canonical
155
+ Before tagging, confirm release-note extraction finds `## [4.0.16]`.
156
+
157
+ ## Releasing 4.0.15
158
+
159
+ The `v4.0.15` tag must identify the exact current `main` commit after canonical
139
160
  CI passes. Do not tag this feature branch directly.
140
161
 
141
162
  ```sh
@@ -144,15 +165,28 @@ git checkout main
144
165
  git pull --ff-only origin main
145
166
 
146
167
  test "$(git branch --show-current)" = "main"
147
- test "$(node -p "require('./package.json').version")" = "4.0.13"
168
+ test "$(node -p "require('./package.json').version")" = "4.0.15"
148
169
  git diff --exit-code
149
170
  git diff --cached --exit-code
150
171
 
151
- git tag -a v4.0.13 -m "v4.0.13"
152
- git push origin v4.0.13
172
+ git tag -a v4.0.15 -m "v4.0.15"
173
+ git push origin v4.0.15
153
174
  ```
154
175
 
155
- Before tagging, confirm release-note extraction finds `## [4.0.13]`.
176
+ Before tagging, confirm release-note extraction finds `## [4.0.15]`.
177
+
178
+ ## Releasing 4.0.14
179
+
180
+ The `v4.0.14` tag is immutable history, but its publish workflow was cancelled
181
+ before the npm version check and publish steps. Do not recreate or move it.
182
+
183
+ ## Releasing 4.0.13
184
+
185
+ The `v4.0.13` tag is immutable published history. Do not recreate or move it.
186
+
187
+ ```sh
188
+ git tag -a v4.0.13 -m "v4.0.13"
189
+ ```
156
190
 
157
191
  ## Releasing 4.0.12
158
192
 
@@ -327,7 +361,7 @@ a green publish job and a package a consumer can actually install are not the
327
361
  same claim.
328
362
 
329
363
  ```sh
330
- version=4.0.12
364
+ version=4.0.16
331
365
 
332
366
  npm view "unified-ble-manager@$version" version
333
367
  npm view unified-ble-manager dist-tags --json
package/SBOM.cdx.json CHANGED
@@ -6,20 +6,20 @@
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "library",
9
- "bom-ref": "pkg:npm/unified-ble-manager@4.0.13",
9
+ "bom-ref": "pkg:npm/unified-ble-manager@4.0.16",
10
10
  "name": "unified-ble-manager",
11
- "version": "4.0.13",
11
+ "version": "4.0.16",
12
12
  "licenses": [
13
13
  {
14
14
  "expression": "Apache-2.0"
15
15
  }
16
16
  ],
17
- "purl": "pkg:npm/unified-ble-manager@4.0.13"
17
+ "purl": "pkg:npm/unified-ble-manager@4.0.16"
18
18
  },
19
19
  "properties": [
20
20
  {
21
21
  "name": "unified-ble-manager:pnpm-lock-sha256",
22
- "value": "58c2b09c18f10de1e250a357e5ce9445fe6bcace614e3778bc60166b9db31fa1"
22
+ "value": "fea8b9fea3dca31d966c33e5942c5fb0f179c492a8462f90482d0c5dc1bd86d2"
23
23
  },
24
24
  {
25
25
  "name": "unified-ble-manager:dependency-scope",
@@ -537,7 +537,7 @@
537
537
  "dependsOn": []
538
538
  },
539
539
  {
540
- "ref": "pkg:npm/unified-ble-manager@4.0.13",
540
+ "ref": "pkg:npm/unified-ble-manager@4.0.16",
541
541
  "dependsOn": [
542
542
  "pkg:npm/%40babel/runtime@7.29.7",
543
543
  "pkg:npm/node-addon-api@8.9.0",
@@ -4,12 +4,12 @@
4
4
  "schemaVersion": "1.0.0",
5
5
  "package": {
6
6
  "name": "unified-ble-manager",
7
- "version": "4.0.13"
7
+ "version": "4.0.16"
8
8
  },
9
9
  "source": {
10
10
  "method": "pnpm-lock production graph with installed-manifest license audit",
11
11
  "lockfile": "pnpm-lock.yaml",
12
- "lockfileSha256": "58c2b09c18f10de1e250a357e5ce9445fe6bcace614e3778bc60166b9db31fa1"
12
+ "lockfileSha256": "fea8b9fea3dca31d966c33e5942c5fb0f179c492a8462f90482d0c5dc1bd86d2"
13
13
  },
14
14
  "reviewedOverrides": [
15
15
  {
@@ -11,6 +11,7 @@ import com.sfourdrinier.unifiedblemanager.protocol.generated.ConnectionIntents
11
11
  import com.sfourdrinier.unifiedblemanager.protocol.generated.RecordKind
12
12
  import com.sfourdrinier.unifiedblemanager.radio.OwnedAndroidGattRadio
13
13
  import com.sfourdrinier.unifiedblemanager.radio.OwnedRadioTeardownFailure
14
+ import com.sfourdrinier.unifiedblemanager.radio.AndroidGattOperationFailure
14
15
  import com.sfourdrinier.unifiedblemanager.radio.BondedPeerSnapshot
15
16
  import com.sfourdrinier.unifiedblemanager.radio.nextUuidOccurrence
16
17
  import java.util.UUID
@@ -38,7 +39,8 @@ class UnifiedBleProtocolAndroidDispatcher(
38
39
  private var attachmentRecord: ProtocolWireRecord? = null
39
40
 
40
41
  init {
41
- radio.onAdapterState = {
42
+ radio.onAdapterState = { adapterState ->
43
+ clearGattProtocolOwnershipForAdapterLoss(adapterState)
42
44
  emitCurrentAdapterState()
43
45
  }
44
46
  radio.onCleanupFailure = { failure ->
@@ -140,6 +142,33 @@ class UnifiedBleProtocolAndroidDispatcher(
140
142
  }
141
143
  }
142
144
 
145
+ private fun clearGattProtocolOwnershipForAdapterLoss(adapterState: String) {
146
+ val state = radio.currentProtocolAdapterState()
147
+ if (state.availability == "available" && state.authorization == "granted" && state.power == "on") return
148
+ val failure = when (adapterState) {
149
+ "PoweredOff" -> AdapterLossConnectFailure(
150
+ "adapterPoweredOff",
151
+ "Android Bluetooth was powered off while the connection was pending"
152
+ )
153
+ "Resetting" -> AdapterLossConnectFailure(
154
+ "adapterResetting",
155
+ "Android Bluetooth reset while the connection was pending"
156
+ )
157
+ else -> AdapterLossConnectFailure(
158
+ "adapterUnavailable",
159
+ "Android Bluetooth became unavailable while the connection was pending"
160
+ )
161
+ }
162
+ pendingConnects.entries.toList().forEach { entry ->
163
+ if (pendingConnects.remove(entry.key, entry.value)) {
164
+ emitFailure(entry.value, failure.code, failure.message)
165
+ }
166
+ }
167
+ establishedConnections.clear()
168
+ activeDatabases.clear()
169
+ activeSubscriptions.clear()
170
+ }
171
+
143
172
  fun emitCurrentAdapterState() {
144
173
  val state = radio.currentProtocolAdapterState()
145
174
  val fields = mutableMapOf<Int, ProtocolWireValue>(
@@ -564,6 +593,11 @@ class UnifiedBleProtocolAndroidDispatcher(
564
593
  }
565
594
 
566
595
  private fun subscribe(command: ProtocolWireRecord, enable: Boolean) {
596
+ val subscriptionId = command.requiredString(7)
597
+ if (!enable && !activeSubscriptions.containsKey(subscriptionId)) {
598
+ emitSuccess(command, "unsubscribed")
599
+ return
600
+ }
567
601
  val endpoint = characteristicEndpoint(command.requiredRecord(4))
568
602
  val radioOperationId = radio.setNotifyExact(
569
603
  endpoint.deviceId,
@@ -597,7 +631,6 @@ class UnifiedBleProtocolAndroidDispatcher(
597
631
  }
598
632
  return@fold
599
633
  }
600
- val subscriptionId = command.requiredString(7)
601
634
  if (enable) {
602
635
  activeSubscriptions[subscriptionId] = SubscriptionRoute(
603
636
  subscriptionId,
@@ -609,7 +642,9 @@ class UnifiedBleProtocolAndroidDispatcher(
609
642
  }
610
643
  emitSuccess(command, if (enable) "subscribed" else "unsubscribed")
611
644
  },
612
- onFailure = { error -> emitFailure(command, "subscriptionFailed", error.message ?: "Android CCCD operation failed") }
645
+ onFailure = { error ->
646
+ emitGattOperationFailure(command, "subscriptionFailed", error, "Android CCCD operation failed")
647
+ }
613
648
  )
614
649
  }
615
650
  radioOperationIds[operationKey(command)] = radioOperationId
@@ -824,21 +859,44 @@ class UnifiedBleProtocolAndroidDispatcher(
824
859
  radioOperationIds.remove(operationKey(command))
825
860
  }
826
861
 
827
- private fun emitFailure(command: ProtocolWireRecord, code: String, message: String) {
862
+ private fun emitGattOperationFailure(
863
+ command: ProtocolWireRecord,
864
+ fallbackCode: String,
865
+ error: Throwable,
866
+ fallbackMessage: String
867
+ ) {
868
+ emitFailure(
869
+ command,
870
+ androidGattOperationFailureCode(error, fallbackCode),
871
+ error.message ?: fallbackMessage,
872
+ androidGattOperationFailureStatus(error)
873
+ )
874
+ }
875
+
876
+ private fun emitFailure(
877
+ command: ProtocolWireRecord,
878
+ code: String,
879
+ message: String,
880
+ androidGattStatus: Int? = null
881
+ ) {
828
882
  val bondedPeerCommand = command.requiredString(3) == "enumerateBondedPeers"
829
883
  if (bondedPeerCommand) {
830
884
  // Keep failure/cancellation/teardown mutually exclusive with a late success.
831
885
  if (!claimExactPendingCommand(pendingCommands, operationKey(command), command)) return
832
886
  } else if (!isPending(command)) return
833
- val error = ProtocolWireRecord(
834
- RecordKind.ERROR,
835
- mapOf(
887
+ val errorFields = mutableMapOf<Int, ProtocolWireValue>(
836
888
  1 to ProtocolWireValue.StringValue(code),
837
889
  2 to ProtocolWireValue.StringValue("android"),
838
890
  3 to ProtocolWireValue.StringValue(command.requiredString(3)),
839
891
  4 to ProtocolWireValue.StringValue("notRetryable"),
840
892
  7 to ProtocolWireValue.StringValue(message)
841
893
  )
894
+ androidGattStatus?.let { status ->
895
+ errorFields[8] = ProtocolWireValue.SignedIntegerValue(status.toLong())
896
+ }
897
+ val error = ProtocolWireRecord(
898
+ RecordKind.ERROR,
899
+ errorFields
842
900
  )
843
901
  val result = ProtocolWireRecord(
844
902
  RecordKind.RESULT,
@@ -1132,6 +1190,11 @@ class UnifiedBleProtocolAndroidDispatcher(
1132
1190
  }
1133
1191
  }
1134
1192
 
1193
+ private data class AdapterLossConnectFailure(
1194
+ val code: String,
1195
+ val message: String
1196
+ )
1197
+
1135
1198
  private fun requirePhyAvailable() {
1136
1199
  check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1137
1200
  "Android PHY requires API 26"
@@ -1158,6 +1221,7 @@ internal fun connectionLostEvent(
1158
1221
  8 to ProtocolWireValue.SignedIntegerValue(status.toLong())
1159
1222
  )
1160
1223
  )
1224
+
1161
1225
  return ProtocolWireRecord(
1162
1226
  RecordKind.EVENT,
1163
1227
  mapOf(
@@ -1173,6 +1237,14 @@ internal fun connectionLostEvent(
1173
1237
  )
1174
1238
  }
1175
1239
 
1240
+ internal fun androidGattOperationFailureCode(error: Throwable, fallbackCode: String): String {
1241
+ val gattFailure = error as? AndroidGattOperationFailure
1242
+ return if (gattFailure?.isLinkLoss == true) "connectionLost" else fallbackCode
1243
+ }
1244
+
1245
+ internal fun androidGattOperationFailureStatus(error: Throwable): Int? =
1246
+ (error as? AndroidGattOperationFailure)?.gattStatus
1247
+
1176
1248
  internal fun databaseChangedEvent(
1177
1249
  nativeHandle: Long,
1178
1250
  database: ProtocolWireRecord,
@@ -109,6 +109,27 @@ internal class OwnedAndroidSubscriptionOwnership<K> {
109
109
  }
110
110
  }
111
111
 
112
+ /**
113
+ * Preserves the Android GATT callback status when an asynchronous operation
114
+ * 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
116
+ * platform failure before the later connection-state callback arrives.
117
+ */
118
+ internal class AndroidGattOperationFailure(
119
+ val operation: String,
120
+ val gattStatus: Int
121
+ ) : IllegalStateException("$operation status=$gattStatus") {
122
+ val isLinkLoss: Boolean
123
+ get() = gattStatus == ANDROID_GATT_LINK_LOSS_STATUS
124
+ }
125
+
126
+ internal const val ANDROID_GATT_LINK_LOSS_STATUS = 19
127
+
128
+ internal fun classifyAndroidGattOperationFailure(
129
+ operation: String,
130
+ gattStatus: Int
131
+ ): AndroidGattOperationFailure = AndroidGattOperationFailure(operation, gattStatus)
132
+
112
133
  /** Runtime adapter state derived from Android hardware and granted permissions. */
113
134
  internal data class OwnedRadioAdapterProtocolState(
114
135
  val availability: String,
@@ -150,6 +171,11 @@ internal fun bondedPeerAdapterReadiness(
150
171
  }
151
172
  }
152
173
 
174
+ internal fun requiresImmediateGattTeardownOnAdapterState(state: Int?): Boolean =
175
+ state == BluetoothAdapter.STATE_OFF ||
176
+ state == BluetoothAdapter.STATE_TURNING_OFF ||
177
+ state == BluetoothAdapter.STATE_TURNING_ON
178
+
153
179
  /** Normalize the platform snapshot before it crosses the Android protocol boundary. */
154
180
  internal fun normalizeBondedPeerSnapshots(
155
181
  peers: Iterable<BondedPeerSnapshot>
@@ -416,6 +442,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
416
442
  override fun onReceive(ctx: Context?, intent: Intent?) {
417
443
  if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
418
444
  val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
445
+ handleAdapterStateTransition(state)
419
446
  onAdapterState?.invoke(mapAdapterState(state))
420
447
  }
421
448
  }
@@ -430,6 +457,23 @@ class OwnedAndroidGattRadio(private val context: Context) {
430
457
  adapterStateReceiver = receiver
431
458
  }
432
459
 
460
+ private fun handleAdapterStateTransition(state: Int) {
461
+ if (state == BluetoothAdapter.STATE_ON) {
462
+ pendingGattTeardowns.keys.toList().forEach { key -> retryGattTeardown(key) }
463
+ return
464
+ }
465
+ if (!requiresImmediateGattTeardownOnAdapterState(state)) return
466
+
467
+ pendingReconnect.clear()
468
+ (gatts.keys + pendingGattTeardowns.keys).toSet().forEach { key ->
469
+ failPendingForDevice(key, "adapter unavailable")
470
+ val owner = pendingGattTeardowns[key]
471
+ val gatt = gatts[key] ?: owner?.gatt ?: return@forEach
472
+ val teardownFailure = completeGattTeardown(key, gatt)
473
+ pendingDisconnectCallbacks.remove(key)?.invoke(teardownFailure)
474
+ }
475
+ }
476
+
433
477
  internal fun unregisterAdapterStateReceiver(): OwnedRadioTeardownFailure? {
434
478
  val receiver = adapterStateReceiver ?: return null
435
479
  try {
@@ -838,6 +882,18 @@ class OwnedAndroidGattRadio(private val context: Context) {
838
882
  onComplete(failure)
839
883
  return failure
840
884
  }
885
+ if (pendingGattTeardowns[key] != null) {
886
+ val teardownFailure = retryGattTeardown(key)
887
+ val failure = cleanupFailure ?: teardownFailure
888
+ onComplete(failure)
889
+ return failure
890
+ }
891
+ if (requiresImmediateGattTeardownOnAdapterState(adapter?.state)) {
892
+ val teardownFailure = completeGattTeardown(key, g)
893
+ val failure = cleanupFailure ?: teardownFailure
894
+ onComplete(failure)
895
+ return failure
896
+ }
841
897
  // R3-F003: only disconnect(); close() waits for STATE_DISCONNECTED (or safety timeout).
842
898
  pendingDisconnectCallbacks[key] = onComplete
843
899
  scheduleSafeClose(key, g)
@@ -923,6 +979,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
923
979
  Runnable {
924
980
  if (gatts[key] === gatt) {
925
981
  OwnedAndroidLog.e("GATT close safety timeout for $key (DISCONNECTED never arrived)")
982
+ dispatchConnectionState(key, false, BluetoothGatt.GATT_FAILURE)
926
983
  failPendingForDevice(key, "disconnected timeout")
927
984
  val teardownFailure = completeGattTeardown(key, gatt)
928
985
  pendingDisconnectCallbacks.remove(key)?.invoke(teardownFailure)
@@ -2443,7 +2500,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
2443
2500
  } else {
2444
2501
  completeExactUnit(
2445
2502
  pending,
2446
- Result.failure(IllegalStateException("onDescriptorWrite status=$status"))
2503
+ Result.failure(classifyAndroidGattOperationFailure("descriptor-write", status))
2447
2504
  )
2448
2505
  }
2449
2506
  return
@@ -2458,7 +2515,7 @@ class OwnedAndroidGattRadio(private val context: Context) {
2458
2515
  } else {
2459
2516
  completeExactUnit(
2460
2517
  pending,
2461
- Result.failure(IllegalStateException("onDescriptorWrite status=$status"))
2518
+ Result.failure(classifyAndroidGattOperationFailure("cccd-write", status))
2462
2519
  )
2463
2520
  }
2464
2521
  return
@@ -2479,7 +2536,14 @@ class OwnedAndroidGattRadio(private val context: Context) {
2479
2536
  if (status == BluetoothGatt.GATT_SUCCESS) {
2480
2537
  cb?.invoke(Result.success(Unit))
2481
2538
  } else {
2482
- cb?.invoke(Result.failure(IllegalStateException("onDescriptorWrite status=$status")))
2539
+ cb?.invoke(
2540
+ Result.failure(
2541
+ classifyAndroidGattOperationFailure(
2542
+ if (matchedKey?.startsWith("cccd:") == true) "cccd-write" else "descriptor-write",
2543
+ status
2544
+ )
2545
+ )
2546
+ )
2483
2547
  }
2484
2548
  }
2485
2549