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),