Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading