unified-ble-manager 4.0.7 → 4.0.9

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 +36 -1
  2. package/MIGRATION_4.0.md +1 -1
  3. package/README.md +27 -2
  4. package/RELEASE.md +43 -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,41 @@
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.9] - 2026-08-29
10
+
11
+ ### Documentation
12
+
13
+ - Correct the README release-truth note so it no longer describes an already
14
+ published version as merely being prepared. The wording now stays truthful
15
+ while a future release branch is ahead of npm and keeps the registry and
16
+ GitHub release as the publication authorities (#179).
17
+
18
+ ## [4.0.8] - 2026-08-29
19
+
20
+ ### Fixes
21
+
22
+ - **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.
23
+
24
+ - **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).
25
+
26
+ - **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).
27
+
28
+ - **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).
29
+
30
+ - **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.
31
+
32
+ - **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.
33
+
34
+ - **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.
35
+
36
+ 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.
37
+
38
+ 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).
39
+
5
40
  ## [4.0.7] - 2026-08-26
6
41
 
7
42
  ### Changed behaviour
@@ -62,7 +97,7 @@ The first release cut against a live peripheral on Android as well as Linux. Mos
62
97
 
63
98
  ## [4.0.5] - 2026-08-25
64
99
 
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`.
100
+ 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
101
 
67
102
  ### Fixes
68
103
 
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.9`. 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,22 @@
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 versioned `4.0.9`. Install the exact version shown in the npm
16
+ registry. During release preparation, the version in `package.json` can be ahead
17
+ of npm until the matching tag-driven workflow publishes it; the registry and
18
+ GitHub release remain authoritative.
19
+
20
+ > **4.0 development note:** The 4.0 line is the real-application proving ground
21
+ > for a simpler, stronger 4.1. Develop carefully against it: pin the version you
22
+ > validate, read the changelog when upgrading, inspect capability limitations,
23
+ > and report real-device behavior. Missing hardware evidence remains visible;
24
+ > it does not make an implemented operation unusable.
10
25
 
11
26
  > Sponsored by [Imagi Explain](https://imagiexplain.com) — researched, narrated whiteboard explainers from a prompt, a PDF, or your notes.
12
27
 
@@ -85,6 +100,12 @@ const manager = await createReactNativeBleManager({
85
100
 
86
101
  On Android 12+ the app must request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` itself. The library does not call `PermissionsAndroid`.
87
102
 
103
+ On Android, `manager.peers.bonded()` lists paired system peers and
104
+ `manager.peers.resolve(reference)` rechecks a saved reference before
105
+ `manager.connect(peer, { intent: 'when-available' })`; paired does not mean
106
+ reachable. See [`docs/PEERS.md`](docs/PEERS.md) for the persistence and error
107
+ semantics.
108
+
88
109
  ### Expo plugin
89
110
 
90
111
  Use an Expo development build, never Expo Go. Plugin options live in
@@ -209,7 +230,11 @@ Web Bluetooth replaces the scan with `ble.choose(...)` from a user gesture. Taur
209
230
 
210
231
  Controls report the truth of the instantiated host backend, including
211
232
  `supported`, `limited`, `unavailable`, or `unsupported`; host family alone is
212
- not evidence of support. Readiness is unsupported until the backend advertises
233
+ not evidence of support. `manager.capabilities.supports(id)` answers whether
234
+ the operation is implemented and invocable, so it returns `true` for both
235
+ `supported` and `limited`. Use `manager.capabilities.get(id)` when application
236
+ policy needs to distinguish full qualification from a named limitation.
237
+ Readiness is unsupported until the backend advertises
213
238
  the readiness capability, and a readiness event does not prove a later payload
214
239
  was retained.
215
240
 
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.8` are immutable. `4.0.9` is the current train head.
85
85
 
86
86
  ```sh
87
87
  release_candidate=4.0.0-rc.N
@@ -133,6 +133,48 @@ 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.9
137
+
138
+ The `v4.0.9` tag must identify the exact current `main` commit after canonical
139
+ CI passes. Do not tag this 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.9"
148
+ git diff --exit-code
149
+ git diff --cached --exit-code
150
+
151
+ git tag -a v4.0.9 -m "v4.0.9"
152
+ git push origin v4.0.9
153
+ ```
154
+
155
+ Before tagging, confirm release-note extraction finds `## [4.0.9]`.
156
+
157
+ ## Releasing 4.0.8
158
+
159
+ The `v4.0.8` tag must identify the exact current `main` commit after canonical
160
+ CI passes. Do not tag this feature branch or `release/4.0.8` directly.
161
+
162
+ ```sh
163
+ git fetch origin --tags
164
+ git checkout main
165
+ git pull --ff-only origin main
166
+
167
+ test "$(git branch --show-current)" = "main"
168
+ test "$(node -p "require('./package.json').version")" = "4.0.8"
169
+ git diff --exit-code
170
+ git diff --cached --exit-code
171
+
172
+ git tag -a v4.0.8 -m "v4.0.8"
173
+ git push origin v4.0.8
174
+ ```
175
+
176
+ Before tagging, confirm release-note extraction finds `## [4.0.8]`.
177
+
136
178
  ## Releasing 4.0.7
137
179
 
138
180
  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.9",
10
10
  "name": "unified-ble-manager",
11
- "version": "4.0.7",
11
+ "version": "4.0.9",
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.9"
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.9",
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.9"
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
  }