mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-26 06:25:24 -04:00
fix(ui): recognize VPN and all networks for network scan availability (#5882)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user