From 0aa00e2011fc6f2db28a26fb2d587cafb6ec26e5 Mon Sep 17 00:00:00 2001
From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Date: Sat, 20 Jun 2026 09:50:31 -0500
Subject: [PATCH] fix(ui): recognize VPN and all networks for network scan
availability
Check all system networks instead of only Android's default network for
WiFi availability. Treat WiFi, Ethernet, and VPN as valid network-scan
transports; keep cellular-only as unavailable. Show the network-scan
warning while the Network section is visible or scan is actively running.
---
.../meshtastic/core/ui/util/PlatformUtils.kt | 80 ++++--
.../core/ui/util/NetworkTransportInfo.kt | 64 +++++
.../meshtastic/core/ui/util/PlatformUtils.kt | 8 +-
.../core/ui/util/NetworkTransportTest.kt | 268 ++++++++++++++++++
.../connections/ui/ConnectionsScreen.kt | 13 +-
5 files changed, 410 insertions(+), 23 deletions(-)
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/NetworkTransportInfo.kt
create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/NetworkTransportTest.kt
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index d538f69b5f..1afcf0d6ab 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -29,6 +29,7 @@ import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
+import android.net.NetworkRequest
import android.net.Uri
import android.os.Handler
import android.os.Looper
@@ -290,34 +291,73 @@ actual fun isWifiUnavailable(): Boolean {
return rememberObservedFlag(
read = {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
- val capabilities = cm?.activeNetwork?.let { cm.getNetworkCapabilities(it) }
- val onLocalNetwork =
- capabilities != null &&
- (
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
- )
- !onLocalNetwork
+ // Scan every current network, not just `activeNetwork`: NSD/mDNS only needs *a* LAN, and
+ // Android often keeps cellular as the default route (or leaves Wi-Fi unvalidated), which
+ // previously stranded the banner "on" even after Wi-Fi returned.
+ cm?.hasLocalNetwork() != true
},
subscribe = { onChange ->
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- val callback =
- object : ConnectivityManager.NetworkCallback() {
- override fun onAvailable(network: Network) = onChange()
-
- override fun onLost(network: Network) = onChange()
-
- override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) =
- onChange()
- }
- // Main-thread Handler so the state write lands on the main thread (the API-26+ overload; minSdk is 26).
- cm.registerDefaultNetworkCallback(callback, Handler(Looper.getMainLooper()))
- val unregister = { cm.unregisterNetworkCallback(callback) }
+ val handler = Handler(Looper.getMainLooper())
+ // Three separate NetworkRequests are registered so changes to any individual transport
+ // (Wi-Fi, Ethernet, or VPN) independently trigger a re-evaluation of local network
+ // availability. Registering per-transport callbacks fires `onChange` on gain/loss/
+ // capability-change of any Wi-Fi, Ethernet, or VPN network. VPN is tracked alongside the
+ // physical LAN transports because a routed overlay (ZeroTier/Tailscale) is a valid
+ // reachability path for a TCP node; cellular-only is not, so CELLULAR is excluded.
+ val wifiRequest = NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build()
+ val ethernetRequest =
+ NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET).build()
+ val vpnRequest = NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_VPN).build()
+ val wifiCallback = localNetworkCallback(onChange)
+ val ethernetCallback = localNetworkCallback(onChange)
+ val vpnCallback = localNetworkCallback(onChange)
+ cm.registerNetworkCallback(wifiRequest, wifiCallback, handler)
+ cm.registerNetworkCallback(ethernetRequest, ethernetCallback, handler)
+ cm.registerNetworkCallback(vpnRequest, vpnCallback, handler)
+ val unregister = {
+ cm.unregisterNetworkCallback(wifiCallback)
+ cm.unregisterNetworkCallback(ethernetCallback)
+ cm.unregisterNetworkCallback(vpnCallback)
+ }
unregister
},
)
}
+/**
+ * Returns `true` if any currently-tracked network carries a Wi-Fi, Ethernet, or VPN transport — the three transports
+ * that can carry TCP traffic to a Meshtastic node and therefore back the network-scan discovery surfaced by
+ * `ConnectionsScreen`. Cellular alone is **not** sufficient. Backs the `read` side of `isWifiUnavailable`; extracted so
+ * the transport reduction can delegate to the platform-agnostic [anyNetworkScanTransportAvailable] helper (which is
+ * unit-tested in `commonTest`).
+ */
+private fun ConnectivityManager.hasLocalNetwork(): Boolean {
+ val transports =
+ allNetworks.mapNotNull { network ->
+ getNetworkCapabilities(network)?.let { caps ->
+ NetworkTransportInfo(
+ hasWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI),
+ hasEthernet = caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET),
+ hasVpn = caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN),
+ )
+ }
+ }
+ return anyNetworkScanTransportAvailable(transports)
+}
+
+/**
+ * Bridges `ConnectivityManager` events to the `rememberObservedFlag` `onChange` trigger. Each callback registration
+ * gets its own instance so `unregisterNetworkCallback` is symmetric.
+ */
+private fun localNetworkCallback(onChange: () -> Unit) = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) = onChange()
+
+ override fun onLost(network: Network) = onChange()
+
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = onChange()
+}
+
@Composable
actual fun rememberOpenAppSettings(): () -> Unit {
val context = LocalContext.current
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/NetworkTransportInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/NetworkTransportInfo.kt
new file mode 100644
index 0000000000..a4f6739185
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/NetworkTransportInfo.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+/**
+ * Transport-type snapshot of a single system network. Filled in by the platform-specific `isWifiUnavailable` actual
+ * (Android's `ConnectivityManager.getNetworkCapabilities`); kept platform-agnostic so the "is any network-scan
+ * transport present?" reduction is unit-testable from `commonTest` without an Android runtime.
+ */
+internal data class NetworkTransportInfo(val hasWifi: Boolean, val hasEthernet: Boolean, val hasVpn: Boolean)
+
+/**
+ * Returns `true` if any of the provided [networks] exposes a Wi-Fi, Ethernet, or VPN transport — the three transports
+ * that can carry TCP traffic to a Meshtastic node and therefore serve as a valid backing path for the network-scan
+ * (NSD/mDNS or direct-IP) discovery used by `ConnectionsScreen`. Drives the `wifiUnavailable` recovery banner: as long
+ * as *any* current network is Wi-Fi, Ethernet, or VPN, the banner stays cleared, regardless of whether the system has
+ * selected one of them as the default route.
+ *
+ * VPN (e.g. ZeroTier, Tailscale) is intentionally included because TCP nodes are routinely reachable over a routed
+ * overlay just as over a physical LAN. Cellular is intentionally **excluded** — a carrier uplink alone does not put the
+ * device on the same L2/L3 segment as a Meshtastic node, so cellular-only must keep the banner shown.
+ *
+ * This reduction deliberately does **not** require `NET_CAPABILITY_VALIDATED` or `NET_CAPABILITY_INTERNET`: a local
+ * mesh access point or a VPN without upstream may be unvalidated yet still carry the TCP traffic the scan needs. The
+ * previous implementation only inspected the default network and only recognized Wi-Fi/Ethernet, so the banner stayed
+ * stuck whenever Android kept cellular as default (or Wi-Fi was connected but unvalidated), and failed to clear when a
+ * VPN provided the actual reachability path.
+ */
+internal fun anyNetworkScanTransportAvailable(networks: List): Boolean =
+ networks.any { it.hasWifi || it.hasEthernet || it.hasVpn }
+
+/**
+ * Returns `true` when the "Wi-Fi unavailable" recovery banner should render in `ConnectionsScreen`.
+ *
+ * The banner surfaces "no usable transport for NSD/mDNS scan" only while the user is actually trying to use network
+ * discovery: when the Network section is visible OR an active scan is in progress.
+ *
+ * The previous gate (`showNetworkTransport &&` alone) missed the auto-scan case — `LifecycleStartEffect` keys
+ * `startNetworkScan()` off `networkAutoScan + permission`, not the section-visibility chip, so a user with the Network
+ * filter chip toggled off but auto-scan on gets a running scan that can't find anything with the recovery banner
+ * suppressed. Widening to `showNetworkTransport || isNetworkScanning` surfaces the hint whenever the user is actually
+ * interacting with network discovery, without overlapping the permission-request flow on the scan toggle (still gated
+ * by [localNetworkPermissionGranted]).
+ */
+fun shouldShowWifiUnavailableBanner(
+ showNetworkTransport: Boolean,
+ isNetworkScanning: Boolean,
+ localNetworkPermissionGranted: Boolean,
+ wifiUnavailable: Boolean,
+): Boolean = (showNetworkTransport || isNetworkScanning) && localNetworkPermissionGranted && wifiUnavailable
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 43909591e3..560521c3c5 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -77,8 +77,12 @@ expect fun rememberSaveFileLauncher(
@Composable expect fun isBluetoothDisabled(): Boolean
/**
- * Returns whether the device currently lacks a local-network-capable connection (no active Wi-Fi or Ethernet). NSD/mDNS
- * discovery needs a LAN, so this surfaces the "connect to Wi-Fi" hint. Always `false` where the concept doesn't apply.
+ * Returns whether the device currently lacks any transport that can back the network-scan discovery (no active Wi-Fi,
+ * Ethernet, or VPN). Cellular alone is **not** sufficient — a carrier uplink does not place the device on the same
+ * segment as a Meshtastic node — so a cellular-only state surfaces the "connect to Wi-Fi" hint. The function name is
+ * historical: the original implementation checked Wi-Fi alone, later widened to Ethernet, and now also recognizes VPN
+ * (ZeroTier/Tailscale) as a valid reachability path for a TCP node. The name is retained to avoid churning the
+ * expect/actual contract and every consumer. Always `false` where the concept doesn't apply.
*/
@Composable expect fun isWifiUnavailable(): Boolean
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/NetworkTransportTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/NetworkTransportTest.kt
new file mode 100644
index 0000000000..6f0c8e55a0
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/NetworkTransportTest.kt
@@ -0,0 +1,268 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Unit coverage for the platform-agnostic reduction that drives `isWifiUnavailable()` on Android. `ConnectivityManager`
+ * itself is not unit-testable here (it needs an Android runtime), so these cases exercise the pure helper that the
+ * Android actual delegates to. The predicate: network-scan transport is available when any current network has Wi-Fi,
+ * Ethernet, or VPN; cellular-only is insufficient. Each case mirrors a real connectivity snapshot the
+ * `ConnectionsScreen` recovery banner must react to correctly.
+ */
+class NetworkTransportTest {
+ @Test
+ fun wifi_network_present_then_local_available() {
+ // Single Wi-Fi network: banner should clear.
+ assertTrue(
+ anyNetworkScanTransportAvailable(
+ listOf(NetworkTransportInfo(hasWifi = true, hasEthernet = false, hasVpn = false)),
+ ),
+ )
+ }
+
+ @Test
+ fun ethernet_network_present_then_local_available() {
+ // Ethernet (e.g. desktop dock / Android tablet on wired LAN) also carries NSD/mDNS traffic.
+ assertTrue(
+ anyNetworkScanTransportAvailable(
+ listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = true, hasVpn = false)),
+ ),
+ )
+ }
+
+ @Test
+ fun only_cellular_network_then_local_unavailable() {
+ // Cellular-only: no LAN, banner should show.
+ assertFalse(
+ anyNetworkScanTransportAvailable(
+ listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false)),
+ ),
+ )
+ }
+
+ @Test
+ fun wifi_present_as_non_default_alongside_cellular_then_local_available() {
+ // The regression case: cellular is the system default (or Wi-Fi is unvalidated), so the
+ // previous `activeNetwork` check missed Wi-Fi. With allNetworks scanning, the banner clears.
+ val cellular = NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false)
+ val wifi = NetworkTransportInfo(hasWifi = true, hasEthernet = false, hasVpn = false)
+ assertTrue(anyNetworkScanTransportAvailable(listOf(cellular, wifi)))
+ }
+
+ @Test
+ fun no_networks_then_local_unavailable() {
+ // Airplane mode / no connectivity at all.
+ assertFalse(anyNetworkScanTransportAvailable(emptyList()))
+ }
+
+ @Test
+ fun wifi_lost_across_all_networks_then_local_unavailable() {
+ // Previously had Wi-Fi, now every tracked network lacks every scan transport: banner returns.
+ val allDropped =
+ listOf(
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false),
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false),
+ )
+ assertFalse(anyNetworkScanTransportAvailable(allDropped))
+ }
+
+ @Test
+ fun wifi_restored_as_non_default_after_loss_then_local_available() {
+ // Symmetric to `wifi_lost_...`: after Wi-Fi returns (even as a non-default network), banner
+ // clears again. Encoded as a state transition through the pure function.
+ val duringOutage = listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false))
+ assertFalse(anyNetworkScanTransportAvailable(duringOutage))
+ val afterRecovery =
+ listOf(
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false),
+ NetworkTransportInfo(hasWifi = true, hasEthernet = false, hasVpn = false),
+ )
+ assertTrue(anyNetworkScanTransportAvailable(afterRecovery))
+ }
+
+ @Test
+ fun vpn_only_then_local_available() {
+ // ZeroTier/Tailscale carry TCP traffic to a node over the routed overlay; banner should clear
+ // even when no physical LAN transport is present.
+ assertTrue(
+ anyNetworkScanTransportAvailable(
+ listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true)),
+ ),
+ )
+ }
+
+ @Test
+ fun wifi_and_vpn_then_local_available() {
+ // Wi-Fi plus an active VPN overlay: both transports independently clear the banner.
+ val wifi = NetworkTransportInfo(hasWifi = true, hasEthernet = false, hasVpn = false)
+ val vpn = NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true)
+ assertTrue(anyNetworkScanTransportAvailable(listOf(wifi, vpn)))
+ }
+
+ @Test
+ fun ethernet_and_vpn_then_local_available() {
+ // Wired plus VPN: same as the Wi-Fi+VPN case for a docked desktop/tablet.
+ val ethernet = NetworkTransportInfo(hasWifi = false, hasEthernet = true, hasVpn = false)
+ val vpn = NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true)
+ assertTrue(anyNetworkScanTransportAvailable(listOf(ethernet, vpn)))
+ }
+
+ @Test
+ fun cellular_and_vpn_then_local_available() {
+ // The original bug report: cellular is the only physical uplink, but a VPN rides on top of it
+ // and that VPN is the reachability path to the node. VPN clears the banner despite cellular.
+ val cellular = NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false)
+ val vpn = NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true)
+ assertTrue(anyNetworkScanTransportAvailable(listOf(cellular, vpn)))
+ }
+
+ @Test
+ fun vpn_lost_leaving_cellular_only_then_local_unavailable() {
+ // Symmetric to `cellular_and_vpn_...`: when the VPN drops, only cellular remains, and the
+ // banner must return. Encoded as a state transition through the pure function.
+ val withVpn =
+ listOf(
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false),
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true),
+ )
+ assertTrue(anyNetworkScanTransportAvailable(withVpn))
+ val afterVpnLost = listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false))
+ assertFalse(anyNetworkScanTransportAvailable(afterVpnLost))
+ }
+
+ @Test
+ fun wifi_lost_leaving_vpn_then_local_available() {
+ // Wi-Fi drops but the VPN (now riding cellular) still reaches the node: banner stays cleared.
+ val duringWifi =
+ listOf(
+ NetworkTransportInfo(hasWifi = true, hasEthernet = false, hasVpn = false),
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true),
+ )
+ assertTrue(anyNetworkScanTransportAvailable(duringWifi))
+ val afterWifiLost = listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true))
+ assertTrue(anyNetworkScanTransportAvailable(afterWifiLost))
+ }
+
+ @Test
+ fun vpn_disabled_leaving_cellular_only_then_local_unavailable() {
+ // The closing bug-report case: ZeroTier disabled, only cellular remains — banner returns.
+ val withVpn =
+ listOf(
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false),
+ NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true),
+ )
+ assertTrue(anyNetworkScanTransportAvailable(withVpn))
+ val afterVpnDisabled = listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = false))
+ assertFalse(anyNetworkScanTransportAvailable(afterVpnDisabled))
+ }
+}
+
+/**
+ * Coverage for the [shouldShowWifiUnavailableBanner] gate consumed by `ConnectionsScreen`. The banner surfaces "no
+ * usable transport for NSD/mDNS scan" only while the user is actually trying to use network discovery. Each case
+ * mirrors one of the four reported combinations of (section-visible, scan-active) plus the permission and
+ * transport-availability guards.
+ */
+class WifiUnavailableBannerTest {
+ private fun transports(wifi: Boolean = false) =
+ // Reuses the predicate's encoding so the banner tests stay in lock-step with transport semantics.
+ listOf(NetworkTransportInfo(hasWifi = wifi, hasEthernet = false, hasVpn = false))
+
+ @Test
+ fun section_visible_permission_granted_cellular_only_then_banner_shows() {
+ // Baseline: Network chip visible, permission granted, cellular-only — banner shows.
+ assertTrue(
+ shouldShowWifiUnavailableBanner(
+ showNetworkTransport = true,
+ isNetworkScanning = false,
+ localNetworkPermissionGranted = true,
+ wifiUnavailable = !anyNetworkScanTransportAvailable(transports()),
+ ),
+ )
+ }
+
+ @Test
+ fun scan_active_section_hidden_cellular_only_then_banner_shows() {
+ // The user-reported regression: Network chip toggled off but auto-scan running in the background,
+ // cellular-only transport. The pre-fix gate (`showNetworkTransport &&` alone) suppressed this case.
+ assertTrue(
+ shouldShowWifiUnavailableBanner(
+ showNetworkTransport = false,
+ isNetworkScanning = true,
+ localNetworkPermissionGranted = true,
+ wifiUnavailable = !anyNetworkScanTransportAvailable(transports()),
+ ),
+ )
+ }
+
+ @Test
+ fun section_hidden_and_scan_inactive_then_banner_hidden() {
+ // User is not interacting with network discovery at all — banner suppressed even on cellular-only.
+ assertFalse(
+ shouldShowWifiUnavailableBanner(
+ showNetworkTransport = false,
+ isNetworkScanning = false,
+ localNetworkPermissionGranted = true,
+ wifiUnavailable = !anyNetworkScanTransportAvailable(transports()),
+ ),
+ )
+ }
+
+ @Test
+ fun permission_denied_then_banner_hidden_even_when_scanning() {
+ // Permission-recovery flow on the scan toggle owns this surface; banner must not overlap.
+ assertFalse(
+ shouldShowWifiUnavailableBanner(
+ showNetworkTransport = true,
+ isNetworkScanning = true,
+ localNetworkPermissionGranted = false,
+ wifiUnavailable = !anyNetworkScanTransportAvailable(transports()),
+ ),
+ )
+ }
+
+ @Test
+ fun wifi_present_then_banner_hidden_even_when_scanning() {
+ // VPN off + Wi-Fi on + scanning → no banner (preserves the existing "transport available" outcome).
+ assertFalse(
+ shouldShowWifiUnavailableBanner(
+ showNetworkTransport = true,
+ isNetworkScanning = true,
+ localNetworkPermissionGranted = true,
+ wifiUnavailable = !anyNetworkScanTransportAvailable(transports(wifi = true)),
+ ),
+ )
+ }
+
+ @Test
+ fun vpn_only_then_banner_hidden_even_when_scanning() {
+ // VPN on + Wi-Fi off + scanning → no banner (VPN is a valid reachability path).
+ val vpnOnly = listOf(NetworkTransportInfo(hasWifi = false, hasEthernet = false, hasVpn = true))
+ assertFalse(
+ shouldShowWifiUnavailableBanner(
+ showNetworkTransport = true,
+ isNetworkScanning = true,
+ localNetworkPermissionGranted = true,
+ wifiUnavailable = !anyNetworkScanTransportAvailable(vpnOnly),
+ ),
+ )
+ }
+}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index 432b3416e6..e03aab1bfb 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -77,6 +77,7 @@ import org.meshtastic.core.ui.util.rememberBluetoothPermissionState
import org.meshtastic.core.ui.util.rememberLocalNetworkPermissionState
import org.meshtastic.core.ui.util.rememberOpenBluetoothSettings
import org.meshtastic.core.ui.util.rememberOpenWifiSettings
+import org.meshtastic.core.ui.util.shouldShowWifiUnavailableBanner
import org.meshtastic.core.ui.viewmodel.ConnectionStatus
import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel
import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX
@@ -294,6 +295,9 @@ fun ConnectionsScreen(
// Adapter-off hints: shown only when the relevant permission is granted but the radio/network
// is unavailable, so they don't overlap the permission-recovery flow on the scan toggles.
+ // The Wi-Fi banner gate includes `isNetworkScanning` because `LifecycleStartEffect` keys the
+ // auto-scan off `networkAutoScan + permission`, not the section-visibility chip — a user with
+ // the Network filter off but auto-scan on still has a running scan that needs the hint.
if (showBleTransport && bluetoothPermission.isGranted && bluetoothDisabled) {
RecoveryCard(
message = stringResource(Res.string.bluetooth_disabled),
@@ -302,7 +306,14 @@ fun ConnectionsScreen(
actionIcon = MeshtasticIcons.Bluetooth,
)
}
- if (showNetworkTransport && localNetworkPermission.isGranted && wifiUnavailable) {
+ if (
+ shouldShowWifiUnavailableBanner(
+ showNetworkTransport = showNetworkTransport,
+ isNetworkScanning = isNetworkScanning,
+ localNetworkPermissionGranted = localNetworkPermission.isGranted,
+ wifiUnavailable = wifiUnavailable,
+ )
+ ) {
RecoveryCard(
message = stringResource(Res.string.wifi_unavailable),
actionLabel = stringResource(Res.string.open_wifi_settings),