unified-ble-manager 4.0.7 → 4.0.8

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 (151) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/MIGRATION_4.0.md +1 -1
  3. package/README.md +25 -2
  4. package/RELEASE.md +22 -1
  5. package/SBOM.cdx.json +4 -4
  6. package/THIRD_PARTY_LICENSES.json +1 -1
  7. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/BlePlxForegroundService.java +78 -17
  8. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/background/AndroidConnectedDeviceForegroundServiceDriver.java +18 -0
  9. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/background/BlePlxForegroundServiceRecoveryReceiver.java +50 -0
  10. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/background/ConnectedDeviceForegroundServiceDriver.java +1 -0
  11. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/background/ConnectedDeviceForegroundServiceLeaseRegistry.java +9 -0
  12. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcher.kt +110 -9
  13. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolControlModule.java +33 -0
  14. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/protocol/generated/NativeProtocolV2Schema.kt +23 -5
  15. package/android/src/main/java/com/sfourdrinier/unifiedblemanager/radio/OwnedAndroidGattRadio.kt +109 -3
  16. package/android/src/main/jni/UnifiedBleProtocolJsiBinding.cpp +26 -21
  17. package/android/src/test/java/com/sfourdrinier/unifiedblemanager/BlePlxForegroundServiceLifecycleTest.kt +61 -0
  18. package/android/src/test/java/com/sfourdrinier/unifiedblemanager/background/ConnectedDeviceForegroundServiceLeaseRegistryTest.kt +21 -0
  19. package/android/src/test/java/com/sfourdrinier/unifiedblemanager/protocol/UnifiedBleProtocolAndroidDispatcherLifecycleTest.kt +304 -0
  20. package/docs/BONDING.md +20 -0
  21. package/docs/ELECTRON.md +1 -1
  22. package/docs/EXPO_PLUGIN.md +30 -3
  23. package/docs/GAPS.4.0.md +3 -1
  24. package/docs/GETTING_STARTED.md +8 -6
  25. package/docs/NODE.md +1 -1
  26. package/docs/PEERS.md +23 -0
  27. package/docs/PLATFORMS.md +72 -1
  28. package/docs/UNIFIED_BLE_4.0_IMPLEMENTATION_PLAN.md +12 -2
  29. package/docs/WEB.md +1 -1
  30. package/docs/generated/PLATFORM_SUPPORT.md +1 -1
  31. package/ios/Generated/NativeProtocolV2Schema.swift +20 -2
  32. package/ios/NativeProtocol/UnifiedBleProtocolAppleExecution.mm +22 -7
  33. package/ios/NativeProtocol/UnifiedBleProtocolAppleExecutionState.hpp +6 -4
  34. package/ios/UnifiedBleProtocolControl.mm +6 -0
  35. package/lib/commonjs/NativeUnifiedBleProtocolControl.js.map +1 -1
  36. package/lib/commonjs/backends/corebluetooth/corebluetooth-backend.js +5 -1
  37. package/lib/commonjs/backends/corebluetooth/corebluetooth-backend.js.map +1 -1
  38. package/lib/commonjs/backends/reactnative/react-native-android-peer-directory.js +115 -0
  39. package/lib/commonjs/backends/reactnative/react-native-android-peer-directory.js.map +1 -0
  40. package/lib/commonjs/backends/reactnative/react-native-android-provider.js +28 -1
  41. package/lib/commonjs/backends/reactnative/react-native-android-provider.js.map +1 -1
  42. package/lib/commonjs/backends/reactnative/react-native-android-security.js +1 -1
  43. package/lib/commonjs/backends/reactnative/react-native-android-security.js.map +1 -1
  44. package/lib/commonjs/expo.js +43 -1
  45. package/lib/commonjs/expo.js.map +1 -1
  46. package/lib/commonjs/implementation-version.js +1 -1
  47. package/lib/commonjs/native-protocol/generated/native-protocol-v2-schema.js +14 -9
  48. package/lib/commonjs/native-protocol/generated/native-protocol-v2-schema.js.map +1 -1
  49. package/lib/commonjs/native-protocol/rn-android-boundary.js +66 -8
  50. package/lib/commonjs/native-protocol/rn-android-boundary.js.map +1 -1
  51. package/lib/commonjs/native-protocol/rn-apple-boundary.js +8 -2
  52. package/lib/commonjs/native-protocol/rn-apple-boundary.js.map +1 -1
  53. package/lib/commonjs/native-protocol/v2-codec.js +25 -0
  54. package/lib/commonjs/native-protocol/v2-codec.js.map +1 -1
  55. package/lib/commonjs/public/capabilities.js +2 -6
  56. package/lib/commonjs/public/capabilities.js.map +1 -1
  57. package/lib/module/NativeUnifiedBleProtocolControl.js.map +1 -1
  58. package/lib/module/backends/corebluetooth/corebluetooth-backend.js +5 -1
  59. package/lib/module/backends/corebluetooth/corebluetooth-backend.js.map +1 -1
  60. package/lib/module/backends/reactnative/react-native-android-peer-directory.js +110 -0
  61. package/lib/module/backends/reactnative/react-native-android-peer-directory.js.map +1 -0
  62. package/lib/module/backends/reactnative/react-native-android-provider.js +28 -1
  63. package/lib/module/backends/reactnative/react-native-android-provider.js.map +1 -1
  64. package/lib/module/backends/reactnative/react-native-android-security.js +1 -1
  65. package/lib/module/backends/reactnative/react-native-android-security.js.map +1 -1
  66. package/lib/module/expo.js +43 -1
  67. package/lib/module/expo.js.map +1 -1
  68. package/lib/module/implementation-version.js +1 -1
  69. package/lib/module/native-protocol/generated/native-protocol-v2-schema.js +13 -8
  70. package/lib/module/native-protocol/generated/native-protocol-v2-schema.js.map +1 -1
  71. package/lib/module/native-protocol/rn-android-boundary.js +66 -8
  72. package/lib/module/native-protocol/rn-android-boundary.js.map +1 -1
  73. package/lib/module/native-protocol/rn-apple-boundary.js +8 -2
  74. package/lib/module/native-protocol/rn-apple-boundary.js.map +1 -1
  75. package/lib/module/native-protocol/v2-codec.js +25 -0
  76. package/lib/module/native-protocol/v2-codec.js.map +1 -1
  77. package/lib/module/public/capabilities.js +2 -6
  78. package/lib/module/public/capabilities.js.map +1 -1
  79. package/lib/typescript/commonjs/src/NativeUnifiedBleProtocolControl.d.ts +6 -0
  80. package/lib/typescript/commonjs/src/NativeUnifiedBleProtocolControl.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  82. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-backend.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-boundary.d.ts +6 -1
  84. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-boundary.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  86. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-peer-directory.d.ts +28 -0
  87. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-peer-directory.d.ts.map +1 -0
  88. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-provider.d.ts +3 -2
  89. package/lib/typescript/commonjs/src/backends/reactnative/react-native-android-provider.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  91. package/lib/typescript/commonjs/src/backends/winrt/winrt-provider.d.ts +1 -1
  92. package/lib/typescript/commonjs/src/expo.d.ts +5 -0
  93. package/lib/typescript/commonjs/src/expo.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/src/implementation-version.d.ts +1 -1
  95. package/lib/typescript/commonjs/src/native-protocol/generated/native-protocol-v2-schema.d.ts +5 -1
  96. package/lib/typescript/commonjs/src/native-protocol/generated/native-protocol-v2-schema.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/src/native-protocol/rn-android-boundary.d.ts +13 -2
  98. package/lib/typescript/commonjs/src/native-protocol/rn-android-boundary.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/src/native-protocol/rn-apple-boundary.d.ts +5 -1
  100. package/lib/typescript/commonjs/src/native-protocol/rn-apple-boundary.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/src/public/capabilities.d.ts +1 -0
  102. package/lib/typescript/commonjs/src/public/capabilities.d.ts.map +1 -1
  103. package/lib/typescript/module/src/NativeUnifiedBleProtocolControl.d.ts +6 -0
  104. package/lib/typescript/module/src/NativeUnifiedBleProtocolControl.d.ts.map +1 -1
  105. package/lib/typescript/module/src/backends/bluez/bluez-backend-provider.d.ts +1 -1
  106. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-backend.d.ts.map +1 -1
  107. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-boundary.d.ts +6 -1
  108. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-boundary.d.ts.map +1 -1
  109. package/lib/typescript/module/src/backends/corebluetooth/corebluetooth-identity.d.ts +1 -1
  110. package/lib/typescript/module/src/backends/reactnative/react-native-android-peer-directory.d.ts +28 -0
  111. package/lib/typescript/module/src/backends/reactnative/react-native-android-peer-directory.d.ts.map +1 -0
  112. package/lib/typescript/module/src/backends/reactnative/react-native-android-provider.d.ts +3 -2
  113. package/lib/typescript/module/src/backends/reactnative/react-native-android-provider.d.ts.map +1 -1
  114. package/lib/typescript/module/src/backends/reactnative/react-native-apple-provider.d.ts +1 -1
  115. package/lib/typescript/module/src/backends/winrt/winrt-provider.d.ts +1 -1
  116. package/lib/typescript/module/src/expo.d.ts +5 -0
  117. package/lib/typescript/module/src/expo.d.ts.map +1 -1
  118. package/lib/typescript/module/src/implementation-version.d.ts +1 -1
  119. package/lib/typescript/module/src/native-protocol/generated/native-protocol-v2-schema.d.ts +5 -1
  120. package/lib/typescript/module/src/native-protocol/generated/native-protocol-v2-schema.d.ts.map +1 -1
  121. package/lib/typescript/module/src/native-protocol/rn-android-boundary.d.ts +13 -2
  122. package/lib/typescript/module/src/native-protocol/rn-android-boundary.d.ts.map +1 -1
  123. package/lib/typescript/module/src/native-protocol/rn-apple-boundary.d.ts +5 -1
  124. package/lib/typescript/module/src/native-protocol/rn-apple-boundary.d.ts.map +1 -1
  125. package/lib/typescript/module/src/public/capabilities.d.ts +1 -0
  126. package/lib/typescript/module/src/public/capabilities.d.ts.map +1 -1
  127. package/native/PREBUILDS.json +5 -5
  128. package/native/electron/corebluetooth/prebuilds/darwin-arm64/unified_ble_corebluetooth.node +0 -0
  129. package/native/electron/corebluetooth/prebuilds/darwin-x64/unified_ble_corebluetooth.node +0 -0
  130. package/native/electron/winrt/prebuilds/win32-arm64/unified_ble_winrt.node +0 -0
  131. package/native/electron/winrt/prebuilds/win32-x64/unified_ble_winrt.node +0 -0
  132. package/native/protocol/generated/NativeProtocolV2Schema.hpp +34 -9
  133. package/native/protocol/schema/native-protocol-v2-abi.json +18 -6
  134. package/native/protocol/schema/native-protocol-v2.json +21 -6
  135. package/native/protocol/src/NativeProtocolV2Codec.cpp +11 -3
  136. package/package.json +1 -1
  137. package/plugin/build/withBLEAndroidForegroundService.d.ts +1 -0
  138. package/plugin/build/withBLEAndroidForegroundService.js +36 -2
  139. package/src/NativeUnifiedBleProtocolControl.ts +7 -0
  140. package/src/backends/corebluetooth/corebluetooth-backend.ts +7 -3
  141. package/src/backends/corebluetooth/corebluetooth-boundary.ts +4 -1
  142. package/src/backends/reactnative/react-native-android-peer-directory.ts +153 -0
  143. package/src/backends/reactnative/react-native-android-provider.ts +37 -0
  144. package/src/backends/reactnative/react-native-android-security.ts +1 -1
  145. package/src/expo.ts +78 -5
  146. package/src/implementation-version.ts +1 -1
  147. package/src/native-protocol/generated/native-protocol-v2-schema.ts +27 -8
  148. package/src/native-protocol/rn-android-boundary.ts +92 -4
  149. package/src/native-protocol/rn-apple-boundary.ts +10 -2
  150. package/src/native-protocol/v2-codec.ts +27 -0
  151. package/src/public/capabilities.ts +3 -6
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to `unified-ble-manager` are documented here.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ No changes yet.
8
+
9
+ ## [4.0.8] - 2026-08-29
10
+
11
+ ### Fixes
12
+
13
+ - **Apple runtime shutdown no longer retains a closed JavaScript attachment through its invoker.** Closing the native protocol now moves the `CallInvoker` out of shared state before scheduling runtime-thread sink cleanup, breaking the state/invoker/callback ownership cycle. iOS, macOS, and tvOS keep the same public behavior while clean teardown no longer leaves JSI functions alive past their runtime.
14
+
15
+ - **Public capability checks no longer hide implemented operations whose evidence is limited.** `manager.capabilities.supports(id)` and `require(id)` now agree with the core manager: both `supported` and invocable `limited` descriptors are usable, while `unavailable`, `unsupported`, and missing capabilities still fail closed. Callers that need full qualification can inspect `manager.capabilities.get(id).state` and its retained limitations. This fixes Android callers incorrectly discarding the bonded `when-available` reconnect path merely because its physical-radio evidence is still labelled `limited` (#174, PR #170).
16
+
17
+ - **Expo Android connected-device monitoring now has an app-controlled notification and explicit lifecycle recovery.** While an active background lease is held, `manager.background.updateNotification({ title, body? })` updates the existing UBM notification in place without acquiring or starting another service. The configured channel, icon, connected-device service type, ongoing state, session-intent policy, and host-app tap are preserved. `restart: 'while-session-intent-exists'` now also manages boot and package-replaced recovery, but only starts the service when native UBM session intent exists; it never scans or reconnects. Once a recovered service is promoted, a package-scoped `FOREGROUND_READY` signal lets an app-owned headless runtime resume without racing Android's background-service restrictions; normal acquisition resolves its existing caller without launching a redundant headless runtime. Background and restart remain absent/`never` by default, and unsupported hosts reject the operation truthfully (#177, PR #170).
18
+
19
+ - **React Native no longer closes a healthy notification subscription during a legitimate burst of native events.** Android and Apple still fail closed on an undrained native-to-JavaScript queue, but both bridges now retain up to 512 records / 1 MiB before declaring overflow. That bounded budget accommodates common catch-up transfers such as roughly 288 five-minute readings delivered in one 24-hour history response while preserving a visible `stream.overflow` terminal if JavaScript genuinely cannot keep up. The change applies only to the two React Native native-event ingress queues; other backends keep their existing buffering and capability truth (#175).
20
+
21
+ - **React Native Android now exposes the system bonded peer directory.** `manager.peers.bonded()` reads the ABI-6 Android bond table, returns deterministic version-1 system-scoped references, and `manager.peers.resolve()` rechecks that a saved reference remains bonded before reconnecting. `manager.connect(peer, { intent: 'when-available' })` reaches Android's queued auto-connect path. Bonded metadata does not imply reachability; Android reports unknown reachability and preserves `permission.denied` instead of returning an empty list. Other backends keep their truthful unsupported boundaries, and Web origin-authorized devices remain distinct from bonded peers.
22
+
23
+ - **React Native Android now honours `PairOptions.transport: 'le'` instead of discarding it.** The public security layer validated and forwarded the selector, but the React Native Android backend dropped it before the native command and always called parameterless `createBond()`. Explicit LE pairing now crosses native-protocol ABI 5 and invokes Android's transport-selecting bond operation with `BluetoothDevice.TRANSPORT_LE`; `'auto'` remains the platform default. Unsupported or rejected explicit selection fails closed rather than silently changing the request. Because Android API 36 cannot publicly query bond state by transport, only an LE-only device's existing bond satisfies an explicit-LE `already-paired` result; dual-mode, classic, and unknown devices retry the directed operation instead of risking a false success from a BR/EDR-only bond. Reflection-wrapped permission failures retain `permissionDenied`. BlueZ and WinRT already pair through BLE-only device objects, while Apple and Web keep generic pairing unsupported; those platform semantics are unchanged.
24
+
25
+ - **Every GATT notification was dropped on both React Native backends, and characteristic and descriptor reads with them.** A notification's payload is carried as a binary reference, and the protocol requires that reference to name the operation the event belongs to — the codec compares the two for equality (`requireBinaryCorrelation`). Both bindings stamped the subscribe's correlation on the event while minting the payload under a correlation of their own (`"notification:<subscription>:<ordinal>"` on Android, `"apple-notification:..."` on Apple), a combination that can never validate. Every notification was therefore refused inside our own codec before reaching a caller, so a subscription delivered nothing at all while the radio received the peer perfectly well. The read paths decorated their correlation the same way (`"read:<epoch>:<nonce>"`, `"apple-read:<nonce>"`) and failed the same check on the result record. All four sites now mint the payload under the owning operation's nonce, as the write path always did.
26
+
27
+ Sharing one correlation across a subscription's notifications is safe: `OwnedBinaryPayloadStore` keys retained payloads by a freshly generated owner token and releases by that token, treating the correlation as metadata it cross-checks rather than as a unique key.
28
+
29
+ The deterministic layer could not catch this, which is why it shipped: the React Native test doubles minted their own notification correlations and omitted the operation correlation entirely, so they modelled a record the native codec refuses to deliver, and the suite stayed green against a shape no device could ever produce. `ReactNativeAndroidProtocolBoundary` now enforces the same rule the native codec enforces, the doubles emit the shape the bindings actually emit, and the codec's own tests pin both the notification event and the read result. Live-device evidence showed the platform receiving a peer's reply chunks while the application received none (#168).
30
+
5
31
  ## [4.0.7] - 2026-08-26
6
32
 
7
33
  ### Changed behaviour
@@ -62,7 +88,7 @@ The first release cut against a live peripheral on Android as well as Linux. Mos
62
88
 
63
89
  ## [4.0.5] - 2026-08-25
64
90
 
65
- First release cut against a live peripheral. Every fix below came from driving a real CGM from an Android phone and a Linux/BlueZ host, and several are defects no unit test had reason to catch. Does not retag `v4.0.4`.
91
+ First release cut against a live peripheral. Every fix below came from driving a real BLE device from an Android phone and a Linux/BlueZ host, and several are defects no unit test had reason to catch. Does not retag `v4.0.4`.
66
92
 
67
93
  ### Fixes
68
94
 
package/MIGRATION_4.0.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Migrating from react-native-ble-plx
4
4
 
5
- The current package is `4.0.7`. 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.8`. 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
@@ -6,7 +6,20 @@
6
6
 
7
7
  It is an evolution of `react-native-ble-plx`, rewritten as a **cross-platform unified product**. One bytes-first BLE model and lifecycle semantics across hosts, with host-specific construction and ownership. The root package never picks a radio for you, and it will not quietly fall back to a simulator or a different backend.
8
8
 
9
- Install `unified-ble-manager` from npm (`4.0.7` on `latest`). The root import does not pick a radio. Package SemVer and backend support labels are independent: each radio backend stays Experimental until live-radio evidence says otherwise. See [`docs/PLATFORMS.md`](docs/PLATFORMS.md).
9
+ Install `unified-ble-manager` from npm. The registry and provenance attached to
10
+ the tag-driven release are the authority for the current `latest` version. The
11
+ root import does not pick a radio. Package SemVer and backend support labels are
12
+ independent: each radio backend keeps its evidence-derived label. See
13
+ [`docs/PLATFORMS.md`](docs/PLATFORMS.md).
14
+
15
+ This source tree is preparing `4.0.8`; that version is installable only after it
16
+ appears in the npm registry.
17
+
18
+ > **4.0 development note:** The 4.0 line is the real-application proving ground
19
+ > for a simpler, stronger 4.1. Develop carefully against it: pin the version you
20
+ > validate, read the changelog when upgrading, inspect capability limitations,
21
+ > and report real-device behavior. Missing hardware evidence remains visible;
22
+ > it does not make an implemented operation unusable.
10
23
 
11
24
  > Sponsored by [Imagi Explain](https://imagiexplain.com) — researched, narrated whiteboard explainers from a prompt, a PDF, or your notes.
12
25
 
@@ -85,6 +98,12 @@ const manager = await createReactNativeBleManager({
85
98
 
86
99
  On Android 12+ the app must request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` itself. The library does not call `PermissionsAndroid`.
87
100
 
101
+ On Android, `manager.peers.bonded()` lists paired system peers and
102
+ `manager.peers.resolve(reference)` rechecks a saved reference before
103
+ `manager.connect(peer, { intent: 'when-available' })`; paired does not mean
104
+ reachable. See [`docs/PEERS.md`](docs/PEERS.md) for the persistence and error
105
+ semantics.
106
+
88
107
  ### Expo plugin
89
108
 
90
109
  Use an Expo development build, never Expo Go. Plugin options live in
@@ -209,7 +228,11 @@ Web Bluetooth replaces the scan with `ble.choose(...)` from a user gesture. Taur
209
228
 
210
229
  Controls report the truth of the instantiated host backend, including
211
230
  `supported`, `limited`, `unavailable`, or `unsupported`; host family alone is
212
- not evidence of support. Readiness is unsupported until the backend advertises
231
+ not evidence of support. `manager.capabilities.supports(id)` answers whether
232
+ the operation is implemented and invocable, so it returns `true` for both
233
+ `supported` and `limited`. Use `manager.capabilities.get(id)` when application
234
+ policy needs to distinguish full qualification from a named limitation.
235
+ Readiness is unsupported until the backend advertises
213
236
  the readiness capability, and a readiness event does not prove a later payload
214
237
  was retained.
215
238
 
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`, `4.0.1`, `4.0.2`, `4.0.3`, `4.0.4`, `4.0.5`, and `4.0.6` are immutable. `4.0.7` is the current train head.
84
+ once tagged. Stable `4.0.0` through `4.0.7` are immutable. `4.0.8` is the current train head.
85
85
 
86
86
  ```sh
87
87
  release_candidate=4.0.0-rc.N
@@ -133,6 +133,27 @@ 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.8
137
+
138
+ The `v4.0.8` tag must identify the exact current `main` commit after canonical
139
+ CI passes. Do not tag this feature branch or `release/4.0.8` 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.8"
148
+ git diff --exit-code
149
+ git diff --cached --exit-code
150
+
151
+ git tag -a v4.0.8 -m "v4.0.8"
152
+ git push origin v4.0.8
153
+ ```
154
+
155
+ Before tagging, confirm release-note extraction finds `## [4.0.8]`.
156
+
136
157
  ## Releasing 4.0.7
137
158
 
138
159
  Same shape as 4.0.6. The release workflow verifies that the tag points at the
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.7",
9
+ "bom-ref": "pkg:npm/unified-ble-manager@4.0.8",
10
10
  "name": "unified-ble-manager",
11
- "version": "4.0.7",
11
+ "version": "4.0.8",
12
12
  "licenses": [
13
13
  {
14
14
  "expression": "Apache-2.0"
15
15
  }
16
16
  ],
17
- "purl": "pkg:npm/unified-ble-manager@4.0.7"
17
+ "purl": "pkg:npm/unified-ble-manager@4.0.8"
18
18
  },
19
19
  "properties": [
20
20
  {
@@ -560,7 +560,7 @@
560
560
  "dependsOn": []
561
561
  },
562
562
  {
563
- "ref": "pkg:npm/unified-ble-manager@4.0.7",
563
+ "ref": "pkg:npm/unified-ble-manager@4.0.8",
564
564
  "dependsOn": [
565
565
  "pkg:npm/%40babel/runtime@7.29.7",
566
566
  "pkg:npm/%40tauri-apps/api@2.11.1",
@@ -4,7 +4,7 @@
4
4
  "schemaVersion": "1.0.0",
5
5
  "package": {
6
6
  "name": "unified-ble-manager",
7
- "version": "4.0.7"
7
+ "version": "4.0.8"
8
8
  },
9
9
  "source": {
10
10
  "method": "pnpm-lock production graph with installed-manifest license audit",
@@ -3,6 +3,7 @@ package com.sfourdrinier.unifiedblemanager;
3
3
  import android.app.Notification;
4
4
  import android.app.NotificationChannel;
5
5
  import android.app.NotificationManager;
6
+ import android.app.PendingIntent;
6
7
  import android.app.Service;
7
8
  import android.content.Context;
8
9
  import android.content.Intent;
@@ -26,6 +27,8 @@ import java.util.Map;
26
27
  /** Explicit connected-device foreground lease. It performs no scan or reconnect work. */
27
28
  public final class BlePlxForegroundService extends Service {
28
29
  public static final String ACTION_START = "com.sfourdrinier.unifiedblemanager.background.START";
30
+ public static final String ACTION_FOREGROUND_READY =
31
+ "com.sfourdrinier.unifiedblemanager.background.FOREGROUND_READY";
29
32
  public static final String EXTRA_ACK = "backgroundStartAcknowledgement";
30
33
  public static final int ACK_STARTED = 1;
31
34
  public static final int ACK_FAILED = 2;
@@ -37,6 +40,8 @@ public final class BlePlxForegroundService extends Service {
37
40
  private static final String EXTRA_BODY = "body";
38
41
  private static final String EXTRA_ICON_NAME = "iconName";
39
42
  private static final String EXTRA_RESTART_STICKY = "restartSticky";
43
+ private static volatile BlePlxForegroundService activeService;
44
+ private volatile ForegroundServiceNotificationConfiguration activeConfiguration;
40
45
 
41
46
  public static Intent startIntent(
42
47
  Context context,
@@ -75,28 +80,15 @@ public final class BlePlxForegroundService extends Service {
75
80
  return START_NOT_STICKY;
76
81
  }
77
82
 
78
- final String channelId = configuration.getChannelId();
79
- final String channelName = configuration.getChannelName();
80
- final String title = configuration.getTitle();
81
- final String body = configuration.getBody();
83
+ activeService = this;
84
+ activeConfiguration = configuration;
82
85
  if (!getSharedPreferences("unified-ble-manager", MODE_PRIVATE)
83
86
  .edit()
84
87
  .putBoolean(SESSION_INTENT_PREFERENCE, configuration.restartWhileSessionIntentExists())
85
88
  .commit()) {
86
89
  throw new IllegalStateException("Android could not persist the foreground-service session intent.");
87
90
  }
88
- ensureChannel(channelId, channelName);
89
- final Notification.Builder builder =
90
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
91
- ? new Notification.Builder(this, channelId)
92
- : new Notification.Builder(this);
93
- builder
94
- .setSmallIcon(iconResource(this, configuration.getIconName()))
95
- .setContentTitle(title)
96
- .setOngoing(true)
97
- .setCategory(Notification.CATEGORY_SERVICE);
98
- if (body != null) builder.setContentText(body);
99
- final Notification notification = builder.build();
91
+ final Notification notification = buildNotification(configuration);
100
92
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
101
93
  startForeground(
102
94
  ForegroundServiceNotificationConfiguration.NOTIFICATION_ID,
@@ -106,6 +98,13 @@ public final class BlePlxForegroundService extends Service {
106
98
  startForeground(ForegroundServiceNotificationConfiguration.NOTIFICATION_ID, notification);
107
99
  }
108
100
  acknowledge(intent, ACK_STARTED, null);
101
+ // A normal acquisition already has a live JS caller waiting on EXTRA_ACK;
102
+ // starting a second headless runtime before that lease commits is both
103
+ // redundant and racy. Only recovery starts (boot, package replacement,
104
+ // or START_STICKY recreation) need the app-owned wake signal.
105
+ if (intent == null || !intent.hasExtra(EXTRA_ACK)) {
106
+ sendBroadcast(new Intent(ACTION_FOREGROUND_READY).setPackage(getPackageName()));
107
+ }
109
108
  return configuration.restartWhileSessionIntentExists() ? START_STICKY : START_NOT_STICKY;
110
109
  } catch (RuntimeException error) {
111
110
  acknowledge(intent, ACK_FAILED, error.getMessage());
@@ -117,16 +116,50 @@ public final class BlePlxForegroundService extends Service {
117
116
  new IllegalStateException("Android could not clear the foreground-service session intent."));
118
117
  }
119
118
  stopSelf();
119
+ activeService = null;
120
+ activeConfiguration = null;
120
121
  return START_NOT_STICKY;
121
122
  }
122
123
  }
123
124
 
124
125
  @Override
125
- public void onDestroy() {
126
+ public synchronized void onDestroy() {
127
+ activeService = null;
128
+ activeConfiguration = null;
126
129
  stopForeground(STOP_FOREGROUND_REMOVE);
127
130
  super.onDestroy();
128
131
  }
129
132
 
133
+ public static void updateNotification(String title, String body) {
134
+ final BlePlxForegroundService service = activeService;
135
+ if (service == null || service.activeConfiguration == null) {
136
+ throw new com.sfourdrinier.unifiedblemanager.background.ForegroundServiceControlException(
137
+ "foregroundServiceNotRunning",
138
+ "The connected-device foreground service is not running; acquire a background lease first.");
139
+ }
140
+ synchronized (service) {
141
+ final ForegroundServiceNotificationConfiguration current = service.activeConfiguration;
142
+ if (current == null) {
143
+ throw new com.sfourdrinier.unifiedblemanager.background.ForegroundServiceControlException(
144
+ "foregroundServiceNotRunning", "The connected-device foreground service is not running; acquire a background lease first.");
145
+ }
146
+ service.activeConfiguration = ForegroundServiceNotificationConfiguration.fromValues(
147
+ current.getChannelId(),
148
+ current.getChannelName(),
149
+ title,
150
+ body,
151
+ current.getIconName(),
152
+ current.restartWhileSessionIntentExists());
153
+ final NotificationManager manager = service.getSystemService(NotificationManager.class);
154
+ if (manager == null) {
155
+ throw new com.sfourdrinier.unifiedblemanager.background.ForegroundServiceControlException(
156
+ "foregroundServiceNotRunning", "Android notification service is unavailable.");
157
+ }
158
+ manager.notify(ForegroundServiceNotificationConfiguration.NOTIFICATION_ID,
159
+ service.buildNotification(service.activeConfiguration));
160
+ }
161
+ }
162
+
130
163
  @Nullable
131
164
  @Override
132
165
  public IBinder onBind(Intent intent) {
@@ -141,6 +174,34 @@ public final class BlePlxForegroundService extends Service {
141
174
  new NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW));
142
175
  }
143
176
 
177
+ private Notification buildNotification(ForegroundServiceNotificationConfiguration configuration) {
178
+ ensureChannel(configuration.getChannelId(), configuration.getChannelName());
179
+ final Notification.Builder builder =
180
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
181
+ ? new Notification.Builder(this, configuration.getChannelId())
182
+ : new Notification.Builder(this);
183
+ final Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName());
184
+ if (launchIntent == null) {
185
+ throw new com.sfourdrinier.unifiedblemanager.background.ForegroundServiceControlException(
186
+ "foregroundServiceNotConfigured", "The host app has no launchable activity for the notification tap.");
187
+ }
188
+ final PendingIntent contentIntent = PendingIntent.getActivity(
189
+ this,
190
+ ForegroundServiceNotificationConfiguration.NOTIFICATION_ID,
191
+ launchIntent,
192
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
193
+ ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
194
+ : PendingIntent.FLAG_UPDATE_CURRENT);
195
+ builder
196
+ .setSmallIcon(iconResource(this, configuration.getIconName()))
197
+ .setContentTitle(configuration.getTitle())
198
+ .setContentIntent(contentIntent)
199
+ .setOngoing(true)
200
+ .setCategory(Notification.CATEGORY_SERVICE);
201
+ if (configuration.getBody() != null) builder.setContentText(configuration.getBody());
202
+ return builder.build();
203
+ }
204
+
144
205
  private static String required(Intent intent, String name) {
145
206
  final String value = intent.getStringExtra(name);
146
207
  if (value == null || value.isEmpty()) {
@@ -32,6 +32,11 @@ public final class AndroidConnectedDeviceForegroundServiceDriver
32
32
 
33
33
  @Override
34
34
  public void start(String reason) {
35
+ if (Looper.myLooper() == Looper.getMainLooper()) {
36
+ throw new ForegroundServiceControlException(
37
+ "foregroundServiceMainThreadUnavailable",
38
+ "Android foreground-service acquisition cannot wait for promotion on the main thread.");
39
+ }
35
40
  final ForegroundServiceNotificationConfiguration configuration = configuration();
36
41
  requireRuntimePermissions();
37
42
  final CountDownLatch acknowledgement = new CountDownLatch(1);
@@ -116,6 +121,19 @@ public final class AndroidConnectedDeviceForegroundServiceDriver
116
121
  }
117
122
  }
118
123
 
124
+ @Override
125
+ public void update(String title, String body) {
126
+ try {
127
+ BlePlxForegroundService.updateNotification(title, body);
128
+ } catch (RuntimeException error) {
129
+ if (error instanceof ForegroundServiceControlException) throw error;
130
+ throw new ForegroundServiceControlException(
131
+ "foregroundServiceNotificationUpdateFailed",
132
+ "Android could not update the connected-device foreground-service notification; retry while the lease is active.",
133
+ error);
134
+ }
135
+ }
136
+
119
137
  private ForegroundServiceNotificationConfiguration configuration() {
120
138
  try {
121
139
  final ApplicationInfo application = context.getPackageManager().getApplicationInfo(
@@ -0,0 +1,50 @@
1
+ package com.sfourdrinier.unifiedblemanager.background;
2
+
3
+ import android.content.BroadcastReceiver;
4
+ import android.content.Context;
5
+ import android.content.Intent;
6
+ import android.os.Build;
7
+ import android.util.Log;
8
+
9
+ import com.sfourdrinier.unifiedblemanager.BlePlxForegroundService;
10
+
11
+ /** Restores only the configured foreground service; it never scans or reconnects. */
12
+ public final class BlePlxForegroundServiceRecoveryReceiver extends BroadcastReceiver {
13
+ private static final String TAG = "UnifiedBleRecovery";
14
+
15
+ @Override
16
+ public void onReceive(Context context, Intent intent) {
17
+ if (intent == null || (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())
18
+ && !Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction()))) return;
19
+ if (!context.getSharedPreferences("unified-ble-manager", Context.MODE_PRIVATE)
20
+ .getBoolean(BlePlxForegroundService.SESSION_INTENT_PREFERENCE, false)) return;
21
+ try {
22
+ final ForegroundServiceNotificationConfiguration configuration = configuration(context);
23
+ if (!configuration.restartWhileSessionIntentExists()) return;
24
+ final Intent start = BlePlxForegroundService.startIntent(context, configuration);
25
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(start);
26
+ else context.startService(start);
27
+ } catch (RuntimeException error) {
28
+ Log.e(TAG, "Connected-device foreground-service recovery failed", error);
29
+ }
30
+ }
31
+
32
+ private static ForegroundServiceNotificationConfiguration configuration(Context context) {
33
+ try {
34
+ final android.content.pm.ApplicationInfo application =
35
+ context.getPackageManager().getApplicationInfo(
36
+ context.getPackageName(), android.content.pm.PackageManager.GET_META_DATA);
37
+ final java.util.Map<String, String> metadata = new java.util.HashMap<>();
38
+ if (application.metaData != null) {
39
+ for (String key : application.metaData.keySet()) {
40
+ final Object value = application.metaData.get(key);
41
+ if (value instanceof String) metadata.put(key, (String) value);
42
+ }
43
+ }
44
+ return ForegroundServiceNotificationConfiguration.fromMetadata(metadata);
45
+ } catch (android.content.pm.PackageManager.NameNotFoundException error) {
46
+ throw new ForegroundServiceControlException(
47
+ "foregroundServiceNotConfigured", "Managed foreground-service metadata is unavailable", error);
48
+ }
49
+ }
50
+ }
@@ -3,4 +3,5 @@ package com.sfourdrinier.unifiedblemanager.background;
3
3
  public interface ConnectedDeviceForegroundServiceDriver {
4
4
  void start(String reason);
5
5
  void stop();
6
+ void update(String title, String body);
6
7
  }
@@ -44,6 +44,15 @@ public final class ConnectedDeviceForegroundServiceLeaseRegistry {
44
44
  leases.clear();
45
45
  }
46
46
 
47
+ public synchronized void update(String leaseId, String title, String body) {
48
+ if (!leases.contains(leaseId)) {
49
+ throw new ForegroundServiceControlException(
50
+ "invalidBackgroundLease",
51
+ "The connected-device background lease is stale or already released.");
52
+ }
53
+ driver.update(title, body);
54
+ }
55
+
47
56
  public synchronized int activeLeaseCount() {
48
57
  return leases.size();
49
58
  }
@@ -7,9 +7,11 @@ import android.content.Context
7
7
  import android.os.Build
8
8
  import android.os.SystemClock
9
9
  import com.sfourdrinier.unifiedblemanager.protocol.generated.NATIVE_PROTOCOL_VERSION
10
+ import com.sfourdrinier.unifiedblemanager.protocol.generated.ConnectionIntents
10
11
  import com.sfourdrinier.unifiedblemanager.protocol.generated.RecordKind
11
12
  import com.sfourdrinier.unifiedblemanager.radio.OwnedAndroidGattRadio
12
13
  import com.sfourdrinier.unifiedblemanager.radio.OwnedRadioTeardownFailure
14
+ import com.sfourdrinier.unifiedblemanager.radio.BondedPeerSnapshot
13
15
  import com.sfourdrinier.unifiedblemanager.radio.nextUuidOccurrence
14
16
  import java.util.UUID
15
17
  import java.util.concurrent.ConcurrentHashMap
@@ -204,6 +206,7 @@ class UnifiedBleProtocolAndroidDispatcher(
204
206
  securityEventsEnabled.set(true)
205
207
  securityCancelPairing(command)
206
208
  }
209
+ "enumerateBondedPeers" -> enumerateBondedPeers(command)
207
210
  "subscribe" -> subscribe(command, true)
208
211
  "unsubscribe" -> subscribe(command, false)
209
212
  "cancel" -> cancel(command)
@@ -286,7 +289,11 @@ class UnifiedBleProtocolAndroidDispatcher(
286
289
  val prior = pendingConnects.putIfAbsent(peerId.uppercase(), command)
287
290
  require(prior == null) { "A protocol connect is already pending for this peer" }
288
291
  try {
289
- radio.connect(peerId, false)
292
+ val autoConnect = when (connectionIntent(command.requiredString(20))) {
293
+ ConnectionIntents.DIRECT -> false
294
+ ConnectionIntents.WHEN_AVAILABLE -> true
295
+ }
296
+ radio.connect(peerId, autoConnect)
290
297
  } catch (error: Exception) {
291
298
  pendingConnects.remove(peerId.uppercase(), command)
292
299
  throw error
@@ -736,7 +743,8 @@ class UnifiedBleProtocolAndroidDispatcher(
736
743
 
737
744
  private fun securityPair(command: ProtocolWireRecord) {
738
745
  val peerId = command.requiredString(15)
739
- val operationId = radio.pair(peerId) { outcome, state ->
746
+ val pairTransport = command.requiredString(19)
747
+ val operationId = radio.pair(peerId, pairTransport) { outcome, state ->
740
748
  if (!isPending(command)) return@pair
741
749
  if (outcome == "rejected") {
742
750
  emitFailure(command, "pairRejected", "Android rejected the system bond request")
@@ -764,7 +772,32 @@ class UnifiedBleProtocolAndroidDispatcher(
764
772
  emitSuccess(command, "accepted")
765
773
  }
766
774
 
775
+ private fun enumerateBondedPeers(command: ProtocolWireRecord) {
776
+ val snapshots = bondedPeerSnapshotRecords(radio.bondedPeerSnapshots())
777
+ emitSuccess(
778
+ command,
779
+ "bondedPeers",
780
+ mapOf(23 to ProtocolWireValue.RecordListValue(snapshots))
781
+ )
782
+ }
783
+
767
784
  private fun emitSuccess(command: ProtocolWireRecord, kind: String, additions: Map<Int, ProtocolWireValue> = emptyMap()) {
785
+ if (kind == "bondedPeers") {
786
+ // Bonded enumeration reads Android metadata synchronously and can block while
787
+ // JSI cancellation or attachment teardown runs concurrently. Claim first so
788
+ // only one terminal path owns this command's result.
789
+ if (!claimExactPendingCommand(pendingCommands, operationKey(command), command)) return
790
+ val records = additions[23]
791
+ require(records is ProtocolWireValue.RecordListValue) {
792
+ "Android bonded peer result is missing its peer snapshot list"
793
+ }
794
+ UnifiedBleProtocolJsiBinding.emitRecord(
795
+ nativeHandle,
796
+ ProtocolWireEncoder.encode(bondedPeerResultRecord(command.requiredRecord(2), records.value))
797
+ )
798
+ radioOperationIds.remove(operationKey(command))
799
+ return
800
+ }
768
801
  if (!isPending(command)) return
769
802
  val fields = mutableMapOf<Int, ProtocolWireValue>(
770
803
  1 to ProtocolWireValue.UnsignedIntegerValue(NATIVE_PROTOCOL_VERSION.toLong()),
@@ -779,13 +812,18 @@ class UnifiedBleProtocolAndroidDispatcher(
779
812
  }
780
813
  }
781
814
  fields.putAll(additions)
782
- UnifiedBleProtocolJsiBinding.emitRecord(nativeHandle, ProtocolWireEncoder.encode(ProtocolWireRecord(RecordKind.RESULT, fields)))
783
- pendingCommands.remove(operationKey(command), command)
815
+ val result = ProtocolWireRecord(RecordKind.RESULT, fields)
816
+ UnifiedBleProtocolJsiBinding.emitRecord(nativeHandle, ProtocolWireEncoder.encode(result))
817
+ claimExactPendingCommand(pendingCommands, operationKey(command), command)
784
818
  radioOperationIds.remove(operationKey(command))
785
819
  }
786
820
 
787
821
  private fun emitFailure(command: ProtocolWireRecord, code: String, message: String) {
788
- if (!isPending(command)) return
822
+ val bondedPeerCommand = command.requiredString(3) == "enumerateBondedPeers"
823
+ if (bondedPeerCommand) {
824
+ // Keep failure/cancellation/teardown mutually exclusive with a late success.
825
+ if (!claimExactPendingCommand(pendingCommands, operationKey(command), command)) return
826
+ } else if (!isPending(command)) return
789
827
  val error = ProtocolWireRecord(
790
828
  RecordKind.ERROR,
791
829
  mapOf(
@@ -806,12 +844,15 @@ class UnifiedBleProtocolAndroidDispatcher(
806
844
  )
807
845
  )
808
846
  UnifiedBleProtocolJsiBinding.emitRecord(nativeHandle, ProtocolWireEncoder.encode(result))
809
- pendingCommands.remove(operationKey(command), command)
847
+ if (!bondedPeerCommand) claimExactPendingCommand(pendingCommands, operationKey(command), command)
810
848
  radioOperationIds.remove(operationKey(command))
811
849
  }
812
850
 
813
851
  private fun emitCancelled(command: ProtocolWireRecord) {
814
- if (!isPending(command)) return
852
+ val bondedPeerCommand = command.requiredString(3) == "enumerateBondedPeers"
853
+ if (bondedPeerCommand) {
854
+ if (!claimExactPendingCommand(pendingCommands, operationKey(command), command)) return
855
+ } else if (!isPending(command)) return
815
856
  val result = ProtocolWireRecord(
816
857
  RecordKind.RESULT,
817
858
  mapOf(
@@ -833,7 +874,7 @@ class UnifiedBleProtocolAndroidDispatcher(
833
874
  )
834
875
  )
835
876
  UnifiedBleProtocolJsiBinding.emitRecord(nativeHandle, ProtocolWireEncoder.encode(result))
836
- pendingCommands.remove(operationKey(command), command)
877
+ if (!bondedPeerCommand) claimExactPendingCommand(pendingCommands, operationKey(command), command)
837
878
  radioOperationIds.remove(operationKey(command))
838
879
  }
839
880
 
@@ -849,7 +890,7 @@ class UnifiedBleProtocolAndroidDispatcher(
849
890
  )
850
891
  )
851
892
  UnifiedBleProtocolJsiBinding.emitRecord(nativeHandle, ProtocolWireEncoder.encode(result))
852
- pendingCommands.remove(operationKey(command), command)
893
+ claimExactPendingCommand(pendingCommands, operationKey(command), command)
853
894
  }
854
895
 
855
896
  private fun emitConnectionLost(connection: ProtocolWireRecord, status: Int) {
@@ -1163,15 +1204,75 @@ internal fun dispatcherResultKindFor(commandKind: String): String = when (comman
1163
1204
  "unsubscribe" -> "unsubscribed"
1164
1205
  "securityState" -> "securityState"
1165
1206
  "securityPair" -> "securityPair"
1207
+ "enumerateBondedPeers" -> "bondedPeers"
1166
1208
  "destroy" -> "destroyed"
1167
1209
  else -> "accepted"
1168
1210
  }
1169
1211
 
1212
+ internal fun bondedPeerResultRecord(
1213
+ correlation: ProtocolWireRecord,
1214
+ snapshots: List<ProtocolWireRecord>
1215
+ ): ProtocolWireRecord = ProtocolWireRecord(
1216
+ RecordKind.RESULT,
1217
+ mapOf(
1218
+ 1 to ProtocolWireValue.UnsignedIntegerValue(NATIVE_PROTOCOL_VERSION.toLong()),
1219
+ 2 to ProtocolWireValue.StringValue("bondedPeers"),
1220
+ 3 to ProtocolWireValue.RecordValue(
1221
+ ProtocolWireRecord(
1222
+ RecordKind.TERMINAL,
1223
+ mapOf(
1224
+ 1 to ProtocolWireValue.RecordValue(correlation),
1225
+ 2 to ProtocolWireValue.StringValue("succeeded")
1226
+ )
1227
+ )
1228
+ ),
1229
+ 23 to ProtocolWireValue.RecordListValue(snapshots)
1230
+ )
1231
+ )
1232
+
1233
+ internal fun bondedPeerSnapshotRecords(
1234
+ snapshots: Iterable<BondedPeerSnapshot>
1235
+ ): List<ProtocolWireRecord> = snapshots.map { peer ->
1236
+ ProtocolWireRecord(
1237
+ RecordKind.BONDED_PEER_SNAPSHOT,
1238
+ buildMap {
1239
+ put(1, ProtocolWireValue.StringValue(peer.nativePeerId))
1240
+ peer.displayName?.let { name -> put(2, ProtocolWireValue.StringValue(name)) }
1241
+ }
1242
+ )
1243
+ }
1244
+
1245
+ /** Atomically claim one pending command by operation key and object identity. */
1246
+ internal fun claimExactPendingCommand(
1247
+ pending: ConcurrentHashMap<String, ProtocolWireRecord>,
1248
+ key: String,
1249
+ command: ProtocolWireRecord
1250
+ ): Boolean {
1251
+ var removed = false
1252
+ pending.computeIfPresent(key) { _, candidate ->
1253
+ if (candidate === command) {
1254
+ removed = true
1255
+ null
1256
+ } else {
1257
+ candidate
1258
+ }
1259
+ }
1260
+ return removed
1261
+ }
1262
+
1170
1263
  private fun ProtocolWireRecord.requiredBoolean(fieldId: Int): Boolean {
1171
1264
  val value = fields[fieldId]
1172
1265
  return if (value is ProtocolWireValue.BooleanValue) value.value else throw IllegalArgumentException("Boolean field is missing")
1173
1266
  }
1174
1267
 
1268
+ private fun connectionIntent(value: String): ConnectionIntents {
1269
+ return when (value) {
1270
+ "direct" -> ConnectionIntents.DIRECT
1271
+ "whenAvailable" -> ConnectionIntents.WHEN_AVAILABLE
1272
+ else -> throw IllegalArgumentException("Connection intent is invalid")
1273
+ }
1274
+ }
1275
+
1175
1276
  private fun ProtocolWireRecord.requiredSignedInteger(fieldId: Int): Long {
1176
1277
  val value = fields[fieldId]
1177
1278
  return if (value is ProtocolWireValue.SignedIntegerValue) value.value else throw IllegalArgumentException("Signed field is missing")