Skip to content

fix(connections): coordinate BLE and TCP scan lifecycle#5887

Merged
jamesarich merged 1 commit into
meshtastic:mainfrom
jeremiah-k:bugfix/scan-coordination
Jun 21, 2026
Merged

fix(connections): coordinate BLE and TCP scan lifecycle#5887
jamesarich merged 1 commit into
meshtastic:mainfrom
jeremiah-k:bugfix/scan-coordination

Conversation

@jeremiah-k

@jeremiah-k jeremiah-k commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

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 isNetworkScanning as the user-intent gate for when the recovery banner should appear.

Key changes

Fixes

  • Enforced mutual exclusion between BLE and network scans:
    • Starting a BLE scan cancels any active network scan first.
    • Starting a network scan cancels any active BLE scan first.
  • Fixed lifecycle-driven BLE scan cycling:
    • BLE auto-start is keyed to screen lifecycle entry instead of bleAutoScan preference writes.
    • Network auto-start is keyed to local-network permission state so permission-grant auto-start still works without restarting on preference writes.
  • Improved async lifecycle cleanup for BLE:
    • Added a scan generation guard so only the latest BLE scan’s cleanup path can clear _isBleScanning.
    • Prevents stale cancellation cleanup from corrupting the state of a newly-started scan.
  • Updated scan stopping during connection flows:
    • 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.
  • Preserved flexible scan policy:
    • Scanning is allowed in Disconnected, Connecting, Connected, and DeviceSleep.
    • This keeps valid workflows like scanning for TCP nodes while connected over BLE.

Features

  • Reworked auto-scan toggle and persistence semantics to align stored intent with actual runtime activation:
    • toggleBleScan() and toggleNetworkScan() persist auto-scan intent only when enabling actually starts the corresponding scan.
    • Enabling one scan type clears the opposite auto-scan preference to match the runtime mutual-exclusion invariant.
    • persistNetworkAutoScanIntent(true) also clears bleAutoScan, covering the permission-deferred network scan path.

Refactors

  • Removed local Compose state collection for bleAutoScan and networkAutoScan in ConnectionsScreen; lifecycle effects now read current StateFlow values directly.
  • Removed the TCP_DEVICE_PREFIX import from ConnectionsScreen by routing manual TCP entry through ScannerViewModel.connectToManualAddress(fullAddress).
  • Added small scan lifecycle helpers and public-method documentation around scan state, auto-scan preferences, and connection entry points.
  • Updated the scanner test harness so BLE scan mocks default to a non-completing flow, matching a real active scan more closely.

Testing

  • Expanded ScannerViewModelTest coverage for:
    • BLE/network mutual cancellation.
    • scan activation across connection states.
    • connection-state transitions not cancelling active scans.
    • auto-scan preference persistence and opposite-pref clearing.
    • persistNetworkAutoScanIntent(true) clearing BLE auto-scan.
    • onSelected() stopping active scans before device/address changes.
    • connectToManualAddress() stopping active network scans before connecting.
  • Updated ScannerViewModelHarness BLE scanner mock behavior:
    • Uses a default non-completing Flow with awaitCancellation() to prevent premature scan-state cleanup under UnconfinedTestDispatcher.
    • Adds Mokkery argument matching support for the default BLE scanner stub.

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.

@github-actions github-actions Bot added the bugfix PR tag label Jun 21, 2026
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

📄 Docs staleness check — advisory

This PR modifies user-facing UI source files but does not update any page under docs/en/user/ or docs/en/developer/.

⚠️ Doc changes propagate to 3 consumers: in-app docs browser, Jekyll site (GitHub Pages), and meshtastic.org (Docusaurus sync). Updating a page in docs/en/ automatically flows to all three.

Changed source files:

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt

What to check:

Changed area Likely doc page
feature/messaging/ docs/en/user/messages-and-channels.md
feature/node/ docs/en/user/nodes.md or docs/en/user/node-metrics.md
feature/map/ docs/en/user/map-and-waypoints.md
feature/connections/ docs/en/user/connections.md
feature/settings/ docs/en/user/settings-radio-user.md or docs/en/user/settings-module-admin.md
feature/firmware/ docs/en/user/firmware.md
feature/intro/ docs/en/user/onboarding.md
feature/discovery/ docs/en/user/discovery.md
feature/docs/ Internal docs infrastructure
core/ui/ docs/en/developer/codebase.md or component-specific user pages

New page checklist (if adding a new doc page):

  1. Create the .md file in docs/en/user/ or docs/en/developer/ with last_updated frontmatter
  2. Register in DocBundleLoader.kt with string resources (in-app browser)
  3. Jekyll and Docusaurus sync pick up new pages automatically — no config change needed

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this check.

Cross-platform note: This check is advisory while doc coverage matures. Both Android and Apple repos use the same skip-docs-check label and advisory severity. See meshtastic/design standards for shared conventions.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

🖼️ Preview staleness check — advisory

This PR modifies UI composables but does not update any *Previews.kt files.

Previews power screenshot tests and in-app docs screenshots. Keeping them current ensures visual regression coverage stays accurate.

Changed UI files:

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt

What to check:

Pattern Preview file convention
feature/{name}/…/ui/ or component/ feature/{name}/…/*Previews.kt
core/ui/…/ core/ui/…/ (previews colocated)

Adding previews checklist:

  1. Create or update a *Previews.kt file in the same module with @PreviewLightDark
  2. Add @Suppress("PreviewPublic") if the preview is consumed by screenshot-tests
  3. Add corresponding @PreviewTest function in screenshot-tests/src/screenshotTest/
  4. Run ./gradlew :screenshot-tests:updateDebugScreenshotTest to generate reference images

If this PR does not require preview updates (e.g., logic-only change, non-visual refactor), add the skip-preview-check label to dismiss.

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.
@jeremiah-k jeremiah-k force-pushed the bugfix/scan-coordination branch from 9fce9ba to 90e3545 Compare June 21, 2026 21:39
@jeremiah-k jeremiah-k marked this pull request as ready for review June 21, 2026 22:03
@jamesarich jamesarich added this pull request to the merge queue Jun 21, 2026
Merged via the queue into meshtastic:main with commit 91629c3 Jun 21, 2026
23 checks passed
@jeremiah-k jeremiah-k deleted the bugfix/scan-coordination branch June 21, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants