fix: BLE shutdown cleanup + battery-tunable power settings#609
fix: BLE shutdown cleanup + battery-tunable power settings#609torlando-tech merged 11 commits intomainfrom
Conversation
Fix timing bug where BLE scanner/advertiser/GATT connections persist after service restart because System.exit(0) kills the process before the async Python shutdown chain can complete. Add synchronous stopImmediate() methods that run directly on Main thread before process exit. Add user-configurable BLE power settings (Performance/Balanced/Battery Saver/ Custom presets) with per-interface controls for scan interval, scan duration, and advertising refresh interval. Settings flow through the full stack: UI → Room DB → Python config → Kotlin BLE bridge → Scanner/Advertiser. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR delivers two cohesive improvements to the Android BLE stack: a synchronous Key changes:
Minor concerns:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant UI as InterfaceConfigDialog
participant VM as InterfaceManagementViewModel
participant DB as Room DB (InterfaceConfig)
participant PY as reticulum_wrapper.py
participant DRV as AndroidBLEDriver
participant IFACE as AndroidBLEInterface
participant BRIDGE as KotlinBLEBridge
participant SCAN as BleScanner
participant ADV as BleAdvertiser
UI->>VM: onConfigUpdate(blePowerPreset, intervals)
VM->>DB: save InterfaceConfig (JSON with power fields)
Note over PY: On service start
DB->>PY: iface dict with ble_power_preset + intervals
PY->>PY: _create_config_file() writes ble_power_preset,\nble_discovery_interval_ms, etc.
Note over IFACE: Interface init
IFACE->>DRV: super().__init__() → driver.start() → KotlinBLEBridge created
IFACE->>DRV: configure_power(preset, intervals)
DRV->>BRIDGE: configurePower(preset, discoveryIntervalMs, …)
BRIDGE->>BRIDGE: powerSettings = BlePowerPreset.getSettings(preset)\n or custom BlePowerSettings
BRIDGE->>SCAN: updatePowerSettings(powerSettings)
BRIDGE->>ADV: updatePowerSettings(powerSettings)
Note over BRIDGE,ADV: On forced shutdown
BRIDGE->>SCAN: stopImmediate()
BRIDGE->>ADV: stopImmediate()
BRIDGE->>BRIDGE: gattServer?.closeImmediate()
BRIDGE->>BRIDGE: gattClient?.closeImmediate()
BRIDGE->>BRIDGE: scope.cancel()
|
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/model/BlePowerSettings.kt
Outdated
Show resolved
Hide resolved
…up, overlap warning - Add @volatile to cross-thread power settings fields (KotlinBLEBridge, BleScanner, BleAdvertiser) for JMM visibility between Python/Kotlin threads - Remove dead **power_kwargs from AndroidBLEDriver.start() - Add ordering-safety comment in AndroidBLEInterface.__init__() - Upgrade closeImmediate() off-thread log from Log.w to Log.e - Deduplicate preset values in InterfaceConfigDialog using BlePowerPreset.getSettings() - Add scan overlap warning when scan duration >= active scan interval Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Suppress TooManyFunctions on BleScanner (cohesive BLE component) - Suppress SwallowedException in BlePowerPreset.fromString() (intentional fallback) - Suppress CyclomaticComplexMethod on configStateToInterfaceConfig (one branch per type) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/server/BleGattServer.kt
Show resolved
Hide resolved
| companion object { | ||
| fun getSettings(preset: BlePowerPreset): BlePowerSettings = | ||
| when (preset) { | ||
| PERFORMANCE -> BlePowerSettings(PERFORMANCE, 3000L, 15000L, 5000L, 30_000L) |
There was a problem hiding this comment.
PERFORMANCE preset has scanDurationMs (5s) > discoveryIntervalMs (3s) — add intent comment
The PERFORMANCE preset intentionally sets a scan window (5 s) that is longer than the inter-scan delay (3 s), yielding a high-duty-cycle pattern (scan 5 s → wait 3 s → repeat). This is valid because the scan loop in BleScanner is sequential (scan completes, then waits currentScanInterval), so scans never literally overlap.
However, the UI in InterfaceConfigDialog.kt warns users when scanDuration ≥ activeInterval while in Custom mode. A reader browsing BlePowerSettings.kt alone could conclude this is an accidental configuration or a bug. A short inline note would prevent that confusion:
| PERFORMANCE -> BlePowerSettings(PERFORMANCE, 3000L, 15000L, 5000L, 30_000L) | |
| PERFORMANCE -> BlePowerSettings(PERFORMANCE, 3000L, 15000L, 5000L, 30_000L) // scanDuration(5s) > interval(3s) intentional — high duty-cycle sequential scanning |
Prompt To Fix With AI
This is a comment left during a code review.
Path: reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/model/BlePowerSettings.kt
Line: 21
Comment:
**PERFORMANCE preset has `scanDurationMs (5s) > discoveryIntervalMs (3s)` — add intent comment**
The PERFORMANCE preset intentionally sets a scan window (5 s) that is longer than the inter-scan delay (3 s), yielding a high-duty-cycle pattern (scan 5 s → wait 3 s → repeat). This is valid because the scan loop in `BleScanner` is sequential (scan completes, then waits `currentScanInterval`), so scans never literally overlap.
However, the UI in `InterfaceConfigDialog.kt` warns users when `scanDuration ≥ activeInterval` while in Custom mode. A reader browsing `BlePowerSettings.kt` alone could conclude this is an accidental configuration or a bug. A short inline note would prevent that confusion:
```suggestion
PERFORMANCE -> BlePowerSettings(PERFORMANCE, 3000L, 15000L, 5000L, 30_000L) // scanDuration(5s) > interval(3s) intentional — high duty-cycle sequential scanning
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…preset comment - BluetoothGattServer.close() is an IPC call that is thread-safe; the guard was silently preventing GATT cleanup when shutdown ran off-Main - Add comment clarifying PERFORMANCE preset's intentional high duty-cycle scan config (scanDuration 5s > interval 3s) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/bridge/KotlinBLEBridge.kt
Show resolved
Hide resolved
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/client/BleScanner.kt
Show resolved
Hide resolved
…n power update - Add BleGattClient.closeImmediate() to synchronously close all outgoing GATT connections during forced shutdown, matching the pattern used by BleGattServer, BleScanner, and BleAdvertiser - Wire gattClient.closeImmediate() into KotlinBLEBridge.stopImmediate() - Reset currentScanInterval in updatePowerSettings() so new intervals take effect immediately instead of waiting for the next adaptive cycle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/client/BleGattClient.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/com/lxmf/messenger/ui/components/InterfaceConfigDialog.kt
Outdated
Show resolved
Hide resolved
…currency Scans are sequential in BleScanner — they can't overlap. The real concern is high duty-cycle scanning increasing battery usage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/server/BleAdvertiser.kt
Show resolved
Hide resolved
| config_lines.append(f" mode = {mode}") | ||
|
|
||
| ble_power_preset = iface.get("ble_power_preset", "balanced") | ||
| config_lines.append(f" ble_power_preset = {ble_power_preset}") |
There was a problem hiding this comment.
Config values written without validation
Power preset values are written directly to the Reticulum config file with no sanitisation:
config_lines.append(f" ble_power_preset = {ble_power_preset}")If a user somehow injects a newline into ble_power_preset (e.g., via a crafted database entry), this would corrupt the generated config file. Consider quoting or stripping newlines, consistent with how other string values are handled elsewhere in the config writer:
| config_lines.append(f" ble_power_preset = {ble_power_preset}") | |
| config_lines.append(f" ble_power_preset = {str(ble_power_preset).strip()}") |
Prompt To Fix With AI
This is a comment left during a code review.
Path: python/reticulum_wrapper.py
Line: 1630
Comment:
**Config values written without validation**
Power preset values are written directly to the Reticulum config file with no sanitisation:
```python
config_lines.append(f" ble_power_preset = {ble_power_preset}")
```
If a user somehow injects a newline into `ble_power_preset` (e.g., via a crafted database entry), this would corrupt the generated config file. Consider quoting or stripping newlines, consistent with how other string values are handled elsewhere in the config writer:
```suggestion
config_lines.append(f" ble_power_preset = {str(ble_power_preset).strip()}")
```
How can I resolve this? If you propose a fix, please make it concise.Without this, switching from BATTERY_SAVER (180s) to PERFORMANCE (30s) while advertising is active would wait up to 3 minutes for the old delay() to complete before the new interval takes effect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/client/BleScanner.kt
Show resolved
Hide resolved
Written from Python thread via updatePowerSettings(), read from Dispatchers.Default in the scan loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/server/BleGattServer.kt
Show resolved
Hide resolved
Snapshot the keepalive jobs before clearing to avoid CME, and use separate try blocks so a failure in job cancellation can't prevent gattServer.close() from running. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| if config and hasattr(self, 'driver') and self.driver is not None: | ||
| power_preset = config.get("ble_power_preset", "balanced") | ||
| self.driver.configure_power( | ||
| preset=power_preset, | ||
| discovery_interval_ms=int(config.get("ble_discovery_interval_ms", 5000)), | ||
| discovery_interval_idle_ms=int(config.get("ble_discovery_interval_idle_ms", 30000)), | ||
| scan_duration_ms=int(config.get("ble_scan_duration_ms", 10000)), | ||
| advertising_refresh_interval_ms=int(config.get("ble_advertising_refresh_interval_ms", 60000)), | ||
| ) |
There was a problem hiding this comment.
Bug: configure_power() is called before the kotlin_bridge is initialized, causing all user-configured BLE power settings to be silently ignored.
Severity: CRITICAL
Suggested Fix
The call to configure_power() should be moved from AndroidBLEInterface.__init__() to a point after the kotlin_bridge has been successfully initialized in the start() method. Alternatively, configure_power() could be called again after the bridge is ready.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: python/ble_modules/android_ble_interface.py#L71-L79
Potential issue: The `AndroidBLEInterface.__init__()` method calls
`self.driver.configure_power()` immediately after the driver is instantiated. However,
the `kotlin_bridge` attribute on the driver is only initialized later when the `start()`
method is called. As a result, the `configure_power()` method finds `self.kotlin_bridge`
is `None`, logs a warning, and does not apply the power settings. Since there is no
subsequent call to configure power after the bridge is initialized, the entire power
settings feature is non-functional.
reticulum/src/main/java/com/lxmf/messenger/reticulum/ble/client/BleGattClient.kt
Show resolved
Hide resolved
…seImmediate() Same pattern as BleGattServer — snapshot the connections map in its own try block so a CME can't prevent individual gatt.close() calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| // Stop BLE immediately before process exit | ||
| if (::managers.isInitialized) { | ||
| try { | ||
| managers.bleCoordinator.stopImmediate() | ||
| } catch (e: Exception) { | ||
| Log.w(TAG, "Error during BLE immediate shutdown in restart", e) | ||
| } |
There was a problem hiding this comment.
Bug: triggerServiceRestart() calls BLE shutdown methods from a background thread via the onStaleHeartbeat callback, but these operations are expected to run on the Main thread.
Severity: MEDIUM
Suggested Fix
In the triggerServiceRestart() function, dispatch the call to managers.bleCoordinator.stopImmediate() to the main thread. This can be achieved by wrapping the call in a withContext(Dispatchers.Main) block to ensure BLE operations execute on the correct thread.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt#L303-L309
Potential issue: The `onStaleHeartbeat` callback, executed on a background thread
(`Dispatchers.Default`), calls `triggerServiceRestart()`. This function then directly
invokes `managers.bleCoordinator.stopImmediate()`, which results in Android BLE APIs
like `stopScan()` and `stopAdvertising()` being called from a background thread. This
violates the documented contract within the BLE methods, which state they expect to be
called from the Main thread. This deviation from best practices and the code's own
intent can lead to improper BLE resource cleanup and unstable behavior.
| managers.eventHandler.stopAll() | ||
| managers.lockManager.releaseAll() | ||
| managers.broadcaster.kill() |
There was a problem hiding this comment.
Safety-net stopImmediate() call missing isInitialized guard
The ACTION_STOP handler (earlier in the same function) wraps its stopImmediate() call in an if (::managers.isInitialized) check. This safety-net call does not, which would throw UninitializedPropertyAccessException if onStartCommand() is reached via the onDestroy / service-cleanup path before managers is ever initialised.
// Safety net: stop BLE if not already stopped by ACTION_STOP
if (::managers.isInitialized) managers.bleCoordinator.stopImmediate()Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt
Line: 272-274
Comment:
**Safety-net `stopImmediate()` call missing `isInitialized` guard**
The `ACTION_STOP` handler (earlier in the same function) wraps its `stopImmediate()` call in an `if (::managers.isInitialized)` check. This safety-net call does not, which would throw `UninitializedPropertyAccessException` if `onStartCommand()` is reached via the `onDestroy` / service-cleanup path before `managers` is ever initialised.
```kotlin
// Safety net: stop BLE if not already stopped by ACTION_STOP
if (::managers.isInitialized) managers.bleCoordinator.stopImmediate()
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
BLE Shutdown Fix: Add synchronous
stopImmediate()methods across the BLE stack (Scanner, Advertiser, GATT Server, Bridge, Coordinator) called beforeSystem.exit(0)to ensure BLE resources are properly released during service shutdown. Fixes persistent BLE connections after disabling the interface.Battery-Tunable BLE Settings: Add user-configurable power presets (Performance / Balanced / Battery Saver / Custom) with granular control over scan intervals, scan duration, and advertising refresh interval. Settings flow through the full stack: UI → Room DB → Python config → Kotlin BLE bridge → Scanner/Advertiser.
Default device name: Changed to empty string so BLE advertising doesn't rename the device unless explicitly configured.
Test plan
stopImmediatelogs appear beforeSystem.exit(0)→ verify no lingering BLE connections./gradlew assembleDebug✅./gradlew :reticulum:test✅🤖 Generated with Claude Code