fix(ui): recognize VPN and all networks for network scan availability (#5882)

This commit is contained in:
Jeremiah K
2026-06-20 12:22:18 -05:00
committed by GitHub
parent bc86134b73
commit 75f229e8e7
5 changed files with 410 additions and 23 deletions

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<NetworkTransportInfo>): 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

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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),
),
)
}
}

View File

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