fix(connections): coordinate BLE and TCP scan lifecycle#5887
Conversation
📄 Docs staleness check — advisoryThis PR modifies user-facing UI source files but does not update any page under
Changed source files: What to check:
New page checklist (if adding a new doc page):
If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the
|
🖼️ Preview staleness check — advisoryThis PR modifies UI composables but does not update any
Changed UI files: What to check:
Adding previews checklist:
If this PR does not require preview updates (e.g., logic-only change, non-visual refactor), add the |
BLE and TCP scans ran independently — both could be active at the same
time, and neither stopped when the user selected a device. The
auto-scan LifecycleStartEffect blocks were keyed on the scan-preference
StateFlows, which caused Compose to dispose and immediately re-run the
effect every time a preference was written. Each dispose called
stopBleScan() (cancelling the scan that had just started) and each
re-run called startBleScan() (starting a new scan), creating a ~150ms
scan → 1-4ms stop→restart cycle that eventually tripped Android's
BluetoothLeScanner rate limit ("registration failed because app is
scanning too frequently").
Coordinate the scan lifecycle in ScannerViewModel and ConnectionsScreen
with the following design:
- Mutual exclusion: each start*Scan() cancels the other active scan
first. Only one of isBleScanning / isNetworkScanning can be true.
- Re-entrancy guards: both start methods early-return when already
scanning.
- Toggle persistence: toggleBleScan and toggleNetworkScan persist the
auto-scan preference only when the scan actually activates, and clear
the opposite preference on success. This keeps persisted state
consistent with the runtime mutual-exclusion invariant, making
screen-entry auto-start deterministic.
- persistNetworkAutoScanIntent(true) also clears bleAutoScan, covering
the permission-deferred path where the user taps Network scan before
local-network permission is granted.
- onSelected() calls stopAllScans() before any connection setup, so
discovery is torn down synchronously when the user picks a device
rather than racing the connection attempt.
- connectToManualAddress() wraps the manual TCP entry flow with the same
stopAllScans() invariant.
- LifecycleStartEffect blocks are keyed on Unit (BLE) and
localNetworkPermission.isGranted (Network) so they fire only on
lifecycle events and permission changes — never on preference writes.
This eliminates the dispose+restart cycle. The toggle handlers start
and stop scans directly; the effects handle screen-entry auto-start
only.
- A generation counter guards the scan coroutine's finally block so a
stale job's async cleanup cannot reset _isBleScanning on a newer scan.
- Scanning is allowed in any ConnectionState. Cross-transport contention
is not a concern: BLE and WiFi are separate radios, NSD/mDNS is
lightweight, and the only real contention window (BLE scan during BLE
handshake) is already handled by onSelected() stopping scans before
changeDeviceAddress() fires.
Test harness: ScannerViewModelHarness provides a default non-completing
scan flow (flow { awaitCancellation() }) so tests don't need to stub
bleScanner.scan() individually. The previous emptyFlow() stubs completed
immediately under UnconfinedTestDispatcher, causing the safeLaunch finally
block to reset isBleScanning before tests could assert.
Tests: 24 tests cover mutual exclusion, toggle persistence with opposite-
pref clearing, onSelected scan-stop ordering for both BLE and TCP,
connectToManualAddress scan-cancel, persistNetworkAutoScanIntent invariant,
and scan success across all connection states.
9fce9ba to
90e3545
Compare
Overview
This change tightens coordination between BLE scanning and TCP/network scanning in the Meshtastic connections feature. It enforces mutual exclusion so only one discovery mode runs at a time, prevents lifecycle-driven BLE scan stop/start cycling, keeps scan state reliable across async cancellation, and stops discovery immediately when initiating a connection. The result is a more stable scan experience without blocking users from scanning for another transport while already connected.
This should land before the related Wi-Fi unavailable banner PR (#5892), because that follow-up uses
isNetworkScanningas the user-intent gate for when the recovery banner should appear.Key changes
Fixes
bleAutoScanpreference writes._isBleScanning.onSelected()stops all active scans before connection, bonding, USB permission, mock, or replay setup.connectToManualAddress()applies the same stop-before-connect invariant for manually-entered TCP addresses.Disconnected,Connecting,Connected, andDeviceSleep.Features
toggleBleScan()andtoggleNetworkScan()persist auto-scan intent only when enabling actually starts the corresponding scan.persistNetworkAutoScanIntent(true)also clearsbleAutoScan, covering the permission-deferred network scan path.Refactors
bleAutoScanandnetworkAutoScaninConnectionsScreen; lifecycle effects now read currentStateFlowvalues directly.TCP_DEVICE_PREFIXimport fromConnectionsScreenby routing manual TCP entry throughScannerViewModel.connectToManualAddress(fullAddress).Testing
ScannerViewModelTestcoverage for:persistNetworkAutoScanIntent(true)clearing BLE auto-scan.onSelected()stopping active scans before device/address changes.connectToManualAddress()stopping active network scans before connecting.ScannerViewModelHarnessBLE scanner mock behavior:FlowwithawaitCancellation()to prevent premature scan-state cleanup underUnconfinedTestDispatcher.Breaking changes / migration
No breaking API or user-facing migration steps are required. Changes are internal to connection scan lifecycle coordination and related test harness behavior.