From 08165620653fc2879a96d040258fc9ea1174b4b0 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 2 Oct 2026 15:05:03 -0400 Subject: [PATCH 1/3] Report Android Wi-Fi and cellular connections to Steam --- .../droiddeck/launcher/core/NetworkReport.kt | 10 +- .../runtime/LinuxNetworkLinkComponent.kt | 114 ++++- .../usr/local/bin/bannerlator-netmanager | 457 +++++++++++------- tools/tests/test_netmanager.py | 241 +++++++++ 4 files changed, 641 insertions(+), 181 deletions(-) create mode 100644 tools/tests/test_netmanager.py diff --git a/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt b/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt index 095f50d33..af8f71303 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt @@ -14,10 +14,8 @@ import java.net.Inet6Address * `network.txt`: what the session was given to work with, for the reports that say "the client * thinks WiFi is off" or "Firefox cannot resolve anything". * - * The runtime has no NetworkManager, so the client's own network panel is blind by design and says - * so in ways that read like a fault. What actually matters is further down: whether the phone had - * a validated link, which DNS servers the app wrote into the runtime's `resolv.conf`, and whether - * the session can reach a name and an address at all. + * The runtime's NetworkManager bridge reports Android's active link. Include whether the phone + * had a validated link and which DNS servers the app wrote into the runtime's `resolv.conf`. * * **No network names.** The SSID is identifying and is deliberately not collected; the transport * type, the addresses' families and the DNS servers are what a diagnosis needs. @@ -100,7 +98,7 @@ object NetworkReport { } append("\nproot makes no network namespace, so the session uses the phone's connection\n") append("directly: IPv4 and IPv6 both pass through, and there is nothing to forward.\n") - append("The Steam client's own network panel drives NetworkManager over D-Bus, which this\n") - append("runtime does not have - \"wifi is off\" there is cosmetic and not evidence.\n") + append("Steam reads Android's transport, connectivity and metering through the runtime's\n") + append("NetworkManager D-Bus bridge. Android settings manage Wi-Fi and cellular connections.\n") } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt index 1b3b24f9e..43a1ff54f 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -4,6 +4,9 @@ import android.content.Context import android.net.ConnectivityManager import android.net.LinkProperties import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager import android.os.Build import android.provider.Settings import android.util.Log @@ -12,6 +15,8 @@ import java.io.File import java.io.IOException import java.net.Inet4Address import java.net.Inet6Address +import org.json.JSONArray +import org.json.JSONObject /** * The device's network link, published for the Linux runtime's processes. @@ -34,14 +39,49 @@ class LinuxNetworkLinkComponent( appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private val lock = Any() private var callback: ConnectivityManager.NetworkCallback? = null + private var currentNetwork: Network? = null + private var currentProperties: LinkProperties? = null + private var currentCapabilities: NetworkCapabilities? = null /** Called before the session starts, so its first process already sees the link. */ - fun publish() = write(connectivity.activeNetwork?.let(connectivity::getLinkProperties)) + fun publish() = synchronized(lock) { + currentNetwork = connectivity.activeNetwork + currentProperties = currentNetwork?.let(connectivity::getLinkProperties) + currentCapabilities = currentNetwork?.let(connectivity::getNetworkCapabilities) + write(currentProperties, currentCapabilities) + } override fun start() { val registered = object : ConnectivityManager.NetworkCallback() { - override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) = write(properties) - override fun onLost(network: Network) = write(null) + override fun onAvailable(network: Network) = synchronized(lock) { + currentNetwork = network + currentProperties = null + currentCapabilities = null + } + + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = synchronized(lock) { + if (network == currentNetwork) { + currentCapabilities = capabilities + if (currentProperties != null) write(currentProperties, capabilities) + } + } + + override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) = synchronized(lock) { + if (network == currentNetwork) { + currentProperties = properties + write(properties, currentCapabilities) + } + } + + override fun onLost(network: Network) = synchronized(lock) { + // A handover can report the old link lost after the new default arrived. + if (network == currentNetwork) { + currentNetwork = null + currentProperties = null + currentCapabilities = null + write(null, null) + } + } } synchronized(lock) { callback = registered } connectivity.registerDefaultNetworkCallback(registered) @@ -56,7 +96,7 @@ class LinuxNetworkLinkComponent( } } - private fun write(properties: LinkProperties?) { + private fun write(properties: LinkProperties?, capabilities: NetworkCapabilities?) { val file = File(rootDir, LINK_FILE) synchronized(lock) { try { @@ -67,10 +107,76 @@ class LinuxNetworkLinkComponent( } catch (e: IOException) { Log.w(TAG, "Could not publish the network link", e) } + writeNetworkState(properties, capabilities) writeResolver(properties) } } + /** NetworkManager's view of Android. Kept separate from netif.c's legacy adapter format. */ + @Suppress("DEPRECATION") + private fun writeNetworkState(properties: LinkProperties?, capabilities: NetworkCapabilities?) { + val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + val transport = when { + capabilities == null -> "none" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn" + else -> "other" + } + val state = JSONObject().apply { + put("transport", transport) + put("interface", properties?.interfaceName ?: "android") + put("mtu", properties?.mtu?.takeIf { it > 0 } ?: DEFAULT_MTU) + put("connected", properties != null && capabilities != null) + put("validated", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true) + put("captivePortal", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) == true) + put("metered", capabilities?.let { !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) }) + put("wifiEnabled", wifiManager?.isWifiEnabled == true) + put("addresses", JSONArray().apply { + properties?.linkAddresses.orEmpty().forEach { + put(JSONObject().put("address", it.address.hostAddress?.substringBefore('%')).put("prefix", it.prefixLength)) + } + }) + put("gateways", JSONArray().apply { + properties?.routes.orEmpty().filter { it.isDefaultRoute }.forEach { + it.gateway?.takeUnless { address -> address.isAnyLocalAddress }?.let { address -> + put(address.hostAddress?.substringBefore('%')) + } + } + }) + put("dns", JSONArray().apply { + properties?.dnsServers.orEmpty().filter { !it.isLinkLocalAddress }.forEach { + put(it.hostAddress?.substringBefore('%')) + } + }) + if (transport == "wifi") { + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + (capabilities?.transportInfo as? WifiInfo) ?: wifiManager?.connectionInfo + } else { + wifiManager?.connectionInfo + } + if (info != null) { + // SSIDs can be redacted by Android. No location permission is needed to + // report the transport; a hidden name simply displays as Wi-Fi in Steam. + info.ssid?.takeUnless { it == WifiManager.UNKNOWN_SSID || it.isEmpty() } + ?.removeSurrounding("\"")?.let { put("ssid", it) } + put("strength", WifiManager.calculateSignalLevel(info.rssi, 101)) + put("frequency", info.frequency.coerceAtLeast(0)) + put("bitrate", info.linkSpeed.coerceAtLeast(0) * 1000) + } + } + } + try { + val file = File(rootDir, "etc/bannerlator-network.json") + val staged = File(file.path + ".staged") + staged.writeText(state.toString()) + if (!staged.renameTo(file)) throw IOException("Could not replace network state") + } catch (e: IOException) { + Log.w(TAG, "Could not publish the network state", e) + } + } + /** * The guest's resolver, from the network it is actually on. glibc reads /etc/resolv.conf, * and the image ships two public servers there, which is why Steam resolves at all - but a diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-netmanager b/tools/linuxfs/overlay/usr/local/bin/bannerlator-netmanager index f47fe058c..bb5b851c1 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-netmanager +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-netmanager @@ -1,20 +1,15 @@ #!/usr/bin/python3 -# Ported from WinNative (maxjivi05, feature/wayland-gamescope e7a224ae), GPL-3.0, with thanks. -# Renamed again for this app; the answers themselves are his. -# -# Answers for NetworkManager on the runtime's system bus, for the Steam client: -# bannerlator-netmanager -# -# The client's network page and its connection indicator are built on NetworkManager. It asks for -# one over the system bus as it starts, and without an answer it leaves out half of what its -# interface then calls: Settings > Internet stops with "StartScanningForNetworks is not a function" -# and the header shows no connection while downloads run. -# -# The network here is Android's: the app has no say in Wi-Fi, and nothing in this runtime could -# scan or join one. So this reports what is true and no more - one connection that is up while -# Android has a route out, under the address the app was given - and refuses what it cannot do. +# Based on WinNative (maxjivi05, feature/wayland-gamescope e7a224ae), GPL-3.0. +# Steam speaks NetworkManager; Android owns the radios, routes and connection. Export the +# app's live snapshot through that API, including the connected Wi-Fi access point. Android +# settings still handle scanning/joining networks; this service never changes the host link. +import ipaddress +import json import socket +import struct import sys +from pathlib import Path +from xml.sax.saxutils import escape import gi @@ -24,207 +19,327 @@ from gi.repository import Gio, GLib # noqa: E402 NAME = "org.freedesktop.NetworkManager" ROOT = "/org/freedesktop/NetworkManager" SETTINGS = ROOT + "/Settings" -DEVICE = ROOT + "/Devices/1" ACTIVE = ROOT + "/ActiveConnection/1" IP4 = ROOT + "/IP4Config/1" +IP6 = ROOT + "/IP6Config/1" +AP = ROOT + "/AccessPoint/1" PROFILE = SETTINGS + "/1" UUID = "b1d0c8e4-57f0-4c0e-9d60-77696e6e6174" -CHECK_SECONDS = 10 +SNAPSHOT = Path("/etc/bannerlator-network.json") +OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager" +PROPERTIES = "org.freedesktop.DBus.Properties" -# NetworkManager's own numbers. -NM_STATE_DISCONNECTED, NM_STATE_CONNECTED_GLOBAL = 20, 70 -NM_CONNECTIVITY_NONE, NM_CONNECTIVITY_FULL = 1, 4 -NM_DEVICE_TYPE_ETHERNET = 1 -NM_DEVICE_STATE_DISCONNECTED, NM_DEVICE_STATE_ACTIVATED = 30, 100 -NM_ACTIVE_CONNECTION_STATE_ACTIVATED = 2 - -INTROSPECTION = """ - - - - - - - - - - - - - - - - - - - - -""" - - -def address(): - """The address Android routes out by, or None without a route. Nothing is sent.""" - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - s.connect(("192.0.2.1", 9)) - return s.getsockname()[0] - except OSError: - return None +METHODS = { + OBJECT_MANAGER: ''' + + + ''', + NAME: ''' + + + + + ''', + NAME + ".Settings": '', + NAME + ".Settings.Connection": '', + NAME + ".Device": '', + NAME + ".Device.Wireless": ''' + + + + + ''', +} def v(signature, value): return GLib.Variant(signature, value) -def objects(ip): - """Every object NetworkManager would export, as its ObjectManager describes them.""" - up = ip is not None +def read_snapshot(): + # Atomic replacement by the app means we never see a partially written handover. + try: + state = json.loads(SNAPSHOT.read_text()) + if not isinstance(state, dict) or not isinstance(state.get("connected"), bool): + return None + return state + except (OSError, ValueError): + return None + + +def legacy_snapshot(): + # Older APKs do not publish transport metadata. Never guess Wi-Fi from an interface name. + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("192.0.2.1", 9)) + ip = sock.getsockname()[0] + return {"connected": True, "transport": "ethernet", "addresses": [{"address": ip, "prefix": 32}]} + except OSError: + return {"connected": False, "transport": "none"} + + +def connection_type(state): + return {"wifi": "802-11-wireless", "cellular": "gsm", "ethernet": "802-3-ethernet", + "vpn": "tun"}.get(state.get("transport"), "generic") + + +def device_path(state): + transport = state.get("transport", "none") + if not state["connected"] and state.get("wifiEnabled"): + transport = "wifi" + return ROOT + "/Devices/" + {"wifi": "Wifi", "cellular": "Cellular", "ethernet": "Ethernet", + "vpn": "Vpn"}.get(transport, "Android") + + +def connection_name(state): + return {"wifi": state.get("ssid") or "Wi-Fi", "cellular": "Cellular", "ethernet": "Ethernet", + "vpn": "VPN"}.get(state.get("transport"), "Android") + + +def connectivity(state): + if not state["connected"]: + return 1 # NONE + if state.get("validated"): + return 4 # FULL + return 2 if state.get("captivePortal") else 3 # PORTAL / LIMITED + + +def metered(state): + return {True: 1, False: 2}.get(state.get("metered"), 0) + + +def family_addresses(state, version): + return [entry for entry in state.get("addresses", []) + if ipaddress.ip_address(entry["address"]).version == version] + + +def ip_config(state, version): + entries = family_addresses(state, version) + gateways = [ip for ip in state.get("gateways", []) if ipaddress.ip_address(ip).version == version] + dns = [ip for ip in state.get("dns", []) if ipaddress.ip_address(ip).version == version] + gateway = gateways[0] if gateways else "" + props = { + "AddressData": v("aa{sv}", [{"address": v("s", a["address"]), "prefix": v("u", a["prefix"])} for a in entries]), + "Gateway": v("s", gateway), "RouteData": v("aa{sv}", []), + "Domains": v("as", []), "Searches": v("as", []), "DnsOptions": v("as", []), "DnsPriority": v("i", 0), + } + if version == 4: + native = lambda ip: struct.unpack("=I", socket.inet_aton(ip))[0] + props.update({ + "Addresses": v("aau", [[native(a["address"]), a["prefix"], native(gateway) if gateway else 0] for a in entries]), + "Routes": v("aau", []), + "NameserverData": v("aa{sv}", [{"address": v("s", ip)} for ip in dns]), + "Nameservers": v("au", [native(ip) for ip in dns]), + "WinsServerData": v("as", []), "WinsServers": v("au", []), + }) + else: + packed = lambda ip: list(socket.inet_pton(socket.AF_INET6, ip)) + props.update({ + "Addresses": v("a(ayuay)", [(packed(a["address"]), a["prefix"], packed(gateway) if gateway else [0] * 16) for a in entries]), + "Routes": v("a(ayuayu)", []), "Nameservers": v("aay", [packed(ip) for ip in dns]), + }) + return props + + +def objects(state): + device = device_path(state) + up = state["connected"] + transport = state.get("transport", "none") + wifi = transport == "wifi" or (not up and state.get("wifiEnabled", False)) + cellular = transport == "cellular" + kind = connection_type(state) + ip4 = up and bool(family_addresses(state, 4)) + ip6 = up and bool(family_addresses(state, 6)) + device_state = 100 if up else 30 found = { ROOT: {NAME: { - "Devices": v("ao", [DEVICE]), - "AllDevices": v("ao", [DEVICE]), + "Devices": v("ao", [device]), "AllDevices": v("ao", [device]), "ActiveConnections": v("ao", [ACTIVE] if up else []), "PrimaryConnection": v("o", ACTIVE if up else "/"), - "PrimaryConnectionType": v("s", "802-3-ethernet" if up else ""), - "ActivatingConnection": v("o", "/"), + "PrimaryConnectionType": v("s", kind if up else ""), "ActivatingConnection": v("o", "/"), "NetworkingEnabled": v("b", True), - "WirelessEnabled": v("b", False), - "WirelessHardwareEnabled": v("b", False), - "WwanEnabled": v("b", False), - "WwanHardwareEnabled": v("b", False), - "Startup": v("b", False), - "Version": v("s", "1.46.0"), - "State": v("u", NM_STATE_CONNECTED_GLOBAL if up else NM_STATE_DISCONNECTED), - "Connectivity": v("u", NM_CONNECTIVITY_FULL if up else NM_CONNECTIVITY_NONE), - "ConnectivityCheckAvailable": v("b", False), - "ConnectivityCheckEnabled": v("b", False), - "Metered": v("u", 0), + "WirelessEnabled": v("b", bool(state.get("wifiEnabled", wifi))), + "WirelessHardwareEnabled": v("b", bool(state.get("wifiEnabled", wifi))), + "WwanEnabled": v("b", cellular), "WwanHardwareEnabled": v("b", cellular), + "Startup": v("b", False), "Version": v("s", "1.46.0"), + "State": v("u", (70 if state.get("validated") else 60) if up else 20), + "Connectivity": v("u", connectivity(state)), + "ConnectivityCheckAvailable": v("b", False), "ConnectivityCheckEnabled": v("b", False), + "Metered": v("u", metered(state)), }}, SETTINGS: {NAME + ".Settings": { - "Connections": v("ao", [PROFILE]), - "Hostname": v("s", socket.gethostname()), - "CanModify": v("b", False), + "Connections": v("ao", [PROFILE]), "Hostname": v("s", socket.gethostname()), "CanModify": v("b", False), }}, PROFILE: {NAME + ".Settings.Connection": { - "Unsaved": v("b", False), - "Flags": v("u", 0), - "Filename": v("s", ""), + "Unsaved": v("b", True), "Flags": v("u", 0), "Filename": v("s", ""), + }}, + device: {NAME + ".Device": { + "Interface": v("s", state.get("interface", "android")), + "IpInterface": v("s", state.get("interface", "android") if up else ""), + "Driver": v("s", "android"), + "DeviceType": v("u", 2 if wifi else 8 if cellular else {"ethernet": 1, "vpn": 16}.get(transport, 14)), + "State": v("u", device_state), "StateReason": v("(uu)", (device_state, 0)), + "ActiveConnection": v("o", ACTIVE if up else "/"), + "Ip4Config": v("o", IP4 if ip4 else "/"), "Ip6Config": v("o", IP6 if ip6 else "/"), + "Dhcp4Config": v("o", "/"), "Dhcp6Config": v("o", "/"), + "AvailableConnections": v("ao", [PROFILE]), "Managed": v("b", True), + "Autoconnect": v("b", False), "Real": v("b", True), "Capabilities": v("u", 1), + "Mtu": v("u", state.get("mtu", 1500)), "HwAddress": v("s", ""), "Metered": v("u", metered(state)), }}, - DEVICE: { - NAME + ".Device": { - "Interface": v("s", "android"), - "IpInterface": v("s", "android" if up else ""), - "Driver": v("s", "android"), - "DeviceType": v("u", NM_DEVICE_TYPE_ETHERNET), - "State": v("u", NM_DEVICE_STATE_ACTIVATED if up else NM_DEVICE_STATE_DISCONNECTED), - "StateReason": v("(uu)", (NM_DEVICE_STATE_ACTIVATED if up else NM_DEVICE_STATE_DISCONNECTED, 0)), - "ActiveConnection": v("o", ACTIVE if up else "/"), - "Ip4Config": v("o", IP4 if up else "/"), - "Ip6Config": v("o", "/"), - "Dhcp4Config": v("o", "/"), - "Dhcp6Config": v("o", "/"), - "AvailableConnections": v("ao", [PROFILE]), - "Managed": v("b", True), - "Autoconnect": v("b", True), - "Real": v("b", True), - "Capabilities": v("u", 1), - "Mtu": v("u", 1500), - "HwAddress": v("s", ""), - }, - NAME + ".Device.Wired": { - "HwAddress": v("s", ""), - "PermHwAddress": v("s", ""), - "Speed": v("u", 0), - "Carrier": v("b", up), - }, - }, } + if wifi: + found[device][NAME + ".Device.Wireless"] = { + "HwAddress": v("s", ""), "PermHwAddress": v("s", ""), "Mode": v("u", 2), + "Bitrate": v("u", state.get("bitrate", 0)), "WirelessCapabilities": v("u", 0), + "AccessPoints": v("ao", [AP] if up else []), "ActiveAccessPoint": v("o", AP if up else "/"), + "LastScan": v("x", -1), + } + if up: + found[AP] = {NAME + ".AccessPoint": { + "Flags": v("u", 0), "WpaFlags": v("u", 0), "RsnFlags": v("u", 0), + "Ssid": v("ay", list((state.get("ssid") or "Wi-Fi").encode("utf-8"))), + "Frequency": v("u", state.get("frequency", 0)), "HwAddress": v("s", ""), "Mode": v("u", 2), + "MaxBitrate": v("u", state.get("bitrate", 0)), + "Strength": v("y", max(0, min(100, state.get("strength", 0)))), "LastSeen": v("i", -1), + }} + elif cellular: + found[device][NAME + ".Device.Modem"] = { + "ModemCapabilities": v("u", 4), "CurrentCapabilities": v("u", 4), + "DeviceId": v("s", "android"), "OperatorCode": v("s", ""), "Apn": v("s", ""), + } + elif transport == "ethernet": + found[device][NAME + ".Device.Wired"] = { + "HwAddress": v("s", ""), "PermHwAddress": v("s", ""), "Speed": v("u", 0), "Carrier": v("b", up), + } if up: found[ACTIVE] = {NAME + ".Connection.Active": { - "Connection": v("o", PROFILE), - "SpecificObject": v("o", "/"), - "Id": v("s", "Android"), - "Uuid": v("s", UUID), - "Type": v("s", "802-3-ethernet"), - "Devices": v("ao", [DEVICE]), - "State": v("u", NM_ACTIVE_CONNECTION_STATE_ACTIVATED), - "StateFlags": v("u", 0), - "Default": v("b", True), - "Ip4Config": v("o", IP4), - "Dhcp4Config": v("o", "/"), - "Default6": v("b", False), - "Ip6Config": v("o", "/"), - "Dhcp6Config": v("o", "/"), - "Vpn": v("b", False), - "Master": v("o", "/"), - }} - found[IP4] = {NAME + ".IP4Config": { - # The one address the app can see; the prefix says only that much is known. - "AddressData": v("aa{sv}", [{"address": v("s", ip), "prefix": v("u", 32)}]), - "Addresses": v("aau", []), - "Gateway": v("s", ""), - "RouteData": v("aa{sv}", []), - "Routes": v("aau", []), - "NameserverData": v("aa{sv}", []), - "Nameservers": v("au", []), - "Domains": v("as", []), - "Searches": v("as", []), - "DnsOptions": v("as", []), - "DnsPriority": v("i", 0), - "WinsServerData": v("as", []), - "WinsServers": v("au", []), + "Connection": v("o", PROFILE), "SpecificObject": v("o", AP if wifi else "/"), + "Id": v("s", connection_name(state)), "Uuid": v("s", UUID), "Type": v("s", kind), + "Devices": v("ao", [device]), "State": v("u", 2), "StateFlags": v("u", 0), + "Default": v("b", ip4), "Default6": v("b", ip6), + "Ip4Config": v("o", IP4 if ip4 else "/"), "Ip6Config": v("o", IP6 if ip6 else "/"), + "Dhcp4Config": v("o", "/"), "Dhcp6Config": v("o", "/"), "Vpn": v("b", False), "Master": v("o", "/"), }} + for version, present, path in ((4, ip4, IP4), (6, ip6, IP6)): + if present: + found[path] = {NAME + f".IP{version}Config": ip_config(state, version)} return found +def settings(state): + result = {"connection": { + "id": v("s", connection_name(state)), "uuid": v("s", UUID), "type": v("s", connection_type(state)), + "interface-name": v("s", state.get("interface", "android")), "metered": v("u", metered(state)), + }} + if state.get("transport") == "wifi": + result["802-11-wireless"] = { + "ssid": v("ay", list((state.get("ssid") or "Wi-Fi").encode("utf-8"))), "mode": v("s", "infrastructure"), + } + elif state.get("transport") == "cellular": + result["gsm"] = {} # Android owns the SIM/APN; no credentials are exported. + return result + + +def interface_info(interface, values): + # Register properties on the actual objects as well as returning ObjectManager snapshots. + # Steam/libnm also use Properties.Get/GetAll and introspect the wireless methods. + props = "".join(f'' + for key, value in values.items()) + node = Gio.DBusNodeInfo.new_for_xml(f'{METHODS.get(interface, "")}{props}') + return node.interfaces[0] + + class NetworkManager: def __init__(self, bus): self.bus = bus - self.ip = address() - node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION) - manager, root, profile, device = node.interfaces - for path, interface in (("/org/freedesktop", manager), (ROOT, root), (PROFILE, profile), (DEVICE, device)): - bus.register_object_with_closures2(path, interface, self.call, None, None) - GLib.timeout_add_seconds(CHECK_SECONDS, self.check) + self.state = read_snapshot() or legacy_snapshot() + self.objects = objects(self.state) + self.registrations = {} + for path in ("/org/freedesktop", ROOT): + bus.register_object(path, interface_info(OBJECT_MANAGER, {}), self.call, None, None) + self.register(self.objects) + GLib.timeout_add_seconds(1, self.check) + + def register(self, found): + wanted = {(path, interface) for path, interfaces in found.items() for interface in interfaces} + for key in self.registrations.keys() - wanted: + self.bus.unregister_object(self.registrations.pop(key)) + for path, interface in wanted - self.registrations.keys(): + self.registrations[(path, interface)] = self.bus.register_object( + path, interface_info(interface, found[path][interface]), self.call, self.get_property, None) + + def get_property(self, bus, sender, path, interface, name): + return self.objects.get(path, {}).get(interface, {}).get(name) def call(self, bus, sender, path, interface, method, args, invocation): if method == "GetManagedObjects": - invocation.return_value(v("(a{oa{sa{sv}}})", (objects(self.ip),))) + invocation.return_value(v("(a{oa{sa{sv}}})", (self.objects,))) elif method == "GetPermissions": invocation.return_value(v("(a{ss})", ({},))) elif method == "CheckConnectivity": self.check() - invocation.return_value(v("(u)", (NM_CONNECTIVITY_FULL if self.ip else NM_CONNECTIVITY_NONE,))) + invocation.return_value(v("(u)", (connectivity(self.state),))) elif method == "GetSettings": - # The name the client shows the connection under. - invocation.return_value(v("(a{sa{sv}})", ({"connection": { - "id": v("s", "Android"), "uuid": v("s", UUID), "type": v("s", "802-3-ethernet")}},))) + invocation.return_value(v("(a{sa{sv}})", (settings(self.state),))) + elif method == "ListConnections": + invocation.return_value(v("(ao)", ([PROFILE],))) + elif method in ("GetAccessPoints", "GetAllAccessPoints"): + invocation.return_value(v("(ao)", ([AP] if AP in self.objects else [],))) + elif method == "RequestScan": + # Only the AP Android is already using is available. Refresh that view; do not + # pretend to scan the host's radio or disrupt its connection. + self.check() + invocation.return_value(v("()", ())) + elif method in ("GetDevices", "GetAllDevices"): + invocation.return_value(v("(ao)", ([device_path(self.state)],))) else: - invocation.return_value(v("(ao)", ([DEVICE],))) + invocation.return_dbus_error(NAME + ".NotSupported", "Manage this connection in Android settings") def check(self): - """Tells the bus when Android gains or loses its route, as NetworkManager would.""" - ip = address() - if (ip is None) != (self.ip is None) or (ip and ip != self.ip): - before, after = objects(self.ip), objects(ip) - self.ip = ip - for path in before.keys() - after.keys(): - self.announce("InterfacesRemoved", path, v("(oas)", (path, list(before[path])))) - for path in after.keys() - before.keys(): - self.announce("InterfacesAdded", path, v("(oa{sa{sv}})", (path, after[path]))) - for path in after.keys() & before.keys(): - for interface, values in after[path].items(): - self.bus.emit_signal(None, path, "org.freedesktop.DBus.Properties", "PropertiesChanged", - v("(sa{sv}as)", (interface, values, []))) - state = after[ROOT][NAME]["State"].unpack() - device = after[DEVICE][NAME + ".Device"]["State"].unpack() - was = before[DEVICE][NAME + ".Device"]["State"].unpack() - self.bus.emit_signal(None, ROOT, NAME, "StateChanged", v("(u)", (state,))) - self.bus.emit_signal(None, DEVICE, NAME + ".Device", "StateChanged", v("(uuu)", (device, was, 0))) + state = read_snapshot() + if state is None: + return True # A transient read failure must not replace a known link with Ethernet. + if state == self.state: + return True + before, after = self.objects, objects(state) + old_state = self.state + self.state, self.objects = state, after + self.register(after) + for path in before.keys() | after.keys(): + removed = before.get(path, {}).keys() - after.get(path, {}).keys() + added = after.get(path, {}).keys() - before.get(path, {}).keys() + if removed: + self.announce("InterfacesRemoved", v("(oas)", (path, list(removed)))) + if added: + self.announce("InterfacesAdded", v("(oa{sa{sv}})", (path, {i: after[path][i] for i in added}))) + for interface in before.get(path, {}).keys() & after.get(path, {}).keys(): + changed = {k: value for k, value in after[path][interface].items() if value != before[path][interface].get(k)} + if changed: + self.bus.emit_signal(None, path, PROPERTIES, "PropertiesChanged", v("(sa{sv}as)", (interface, changed, []))) + was_ap, has_ap = AP in before, AP in after + if was_ap != has_ap: + # Wireless consumers subscribe to these as well as ObjectManager. + device = device_path(state) + if NAME + ".Device.Wireless" in after[device]: + self.bus.emit_signal(None, device, NAME + ".Device.Wireless", + "AccessPointAdded" if has_ap else "AccessPointRemoved", v("(o)", (AP,))) + was = before[ROOT][NAME]["State"] + now = after[ROOT][NAME]["State"] + if now != was: + self.bus.emit_signal(None, ROOT, NAME, "StateChanged", v("(u)", (now.unpack(),))) + device = device_path(state) + if device in before: + was = before[device][NAME + ".Device"]["State"] + now = after[device][NAME + ".Device"]["State"] + if now != was: + self.bus.emit_signal(None, device, NAME + ".Device", "StateChanged", v("(uuu)", (now.unpack(), was.unpack(), 0))) + if settings(old_state) != settings(state): + self.bus.emit_signal(None, PROFILE, NAME + ".Settings.Connection", "Updated", v("()", ())) return True - def announce(self, signal, path, body): - self.bus.emit_signal(None, "/org/freedesktop", "org.freedesktop.DBus.ObjectManager", signal, body) + def announce(self, signal, body): + for path in ("/org/freedesktop", ROOT): + self.bus.emit_signal(None, path, OBJECT_MANAGER, signal, body) def main(): diff --git a/tools/tests/test_netmanager.py b/tools/tests/test_netmanager.py new file mode 100644 index 000000000..dee6a15fa --- /dev/null +++ b/tools/tests/test_netmanager.py @@ -0,0 +1,241 @@ +"""NetworkManager contract and real D-Bus handovers (requires python3-gi and dbus). + +Run on Linux: dbus-run-session -- python3 -m unittest discover -s tools/tests -p test_netmanager.py +""" +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest + +try: + import gi + gi.require_version("Gio", "2.0") + from gi.repository import Gio, GLib +except ImportError: + Gio = GLib = None + +SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/bannerlator-netmanager" +if Gio: + loader = importlib.machinery.SourceFileLoader("netmanager", str(SCRIPT)) + spec = importlib.util.spec_from_loader(loader.name, loader) + nm = importlib.util.module_from_spec(spec) + loader.exec_module(nm) + +WIFI = { + "connected": True, "transport": "wifi", "interface": "wlan0", "wifiEnabled": True, + "validated": True, "captivePortal": False, "metered": False, + "ssid": "Test \"Wi-Fi\" ☃", "strength": 73, "frequency": 5180, "bitrate": 866000, + "addresses": [{"address": "192.0.2.2", "prefix": 24}, {"address": "2001:db8::2", "prefix": 64}], + "gateways": ["192.0.2.1", "2001:db8::1"], "dns": ["192.0.2.1", "2001:db8::1"], +} +CELLULAR = { + "connected": True, "transport": "cellular", "interface": "rmnet_data0", "wifiEnabled": True, + "validated": True, "metered": True, + "addresses": [{"address": "2001:db8:1::2", "prefix": 64}], + "gateways": ["2001:db8:1::1"], "dns": ["2001:db8:1::1"], +} +OFFLINE = {"connected": False, "transport": "none", "wifiEnabled": True, "metered": None} + + +@unittest.skipUnless(Gio, "requires PyGObject") +class NetworkObjectsTest(unittest.TestCase): + def test_wifi_graph(self): + found = nm.objects(WIFI) + manager = found[nm.ROOT][nm.NAME] + device = found[nm.device_path(WIFI)] + self.assertEqual(manager["PrimaryConnectionType"].unpack(), "802-11-wireless") + self.assertTrue(manager["WirelessEnabled"].unpack()) + self.assertEqual(device[nm.NAME + ".Device"]["DeviceType"].unpack(), 2) + self.assertEqual(device[nm.NAME + ".Device.Wireless"]["ActiveAccessPoint"].unpack(), nm.AP) + ap = found[nm.AP][nm.NAME + ".AccessPoint"] + self.assertEqual(bytes(ap["Ssid"].unpack()).decode(), WIFI["ssid"]) + self.assertEqual(ap["Strength"].unpack(), 73) + self.assertEqual(manager["Metered"].unpack(), 2) + for interfaces in found.values(): + for values in interfaces.values(): + for value in values.values(): + if value.get_type_string() == "o" and value.unpack() != "/": + self.assertIn(value.unpack(), found) + + def test_cellular_ipv6_only(self): + found = nm.objects(CELLULAR) + manager = found[nm.ROOT][nm.NAME] + device = found[nm.device_path(CELLULAR)] + active = found[nm.ACTIVE][nm.NAME + ".Connection.Active"] + self.assertEqual(manager["PrimaryConnectionType"].unpack(), "gsm") + self.assertEqual(manager["Metered"].unpack(), 1) + self.assertEqual(device[nm.NAME + ".Device"]["DeviceType"].unpack(), 8) + self.assertIn(nm.NAME + ".Device.Modem", device) + self.assertNotIn(nm.AP, found) + self.assertNotIn(nm.IP4, found) + self.assertEqual(active["Ip4Config"].unpack(), "/") + self.assertEqual(active["Ip6Config"].unpack(), nm.IP6) + self.assertTrue(active["Default6"].unpack()) + self.assertFalse(active["Default"].unpack()) + self.assertNotEqual(nm.device_path(WIFI), nm.device_path(CELLULAR)) + + def test_offline_and_captive_portal(self): + offline = nm.objects(OFFLINE) + self.assertEqual(offline[nm.ROOT][nm.NAME]["State"].unpack(), 20) + self.assertEqual(offline[nm.ROOT][nm.NAME]["ActiveConnections"].unpack(), []) + self.assertNotIn(nm.AP, offline) + self.assertEqual(nm.connectivity(dict(WIFI, validated=False, captivePortal=True)), 2) + self.assertEqual(nm.connectivity(dict(WIFI, validated=False, captivePortal=False)), 3) + self.assertEqual(nm.metered(OFFLINE), 0) + + def test_redacted_name_and_wired_vpn(self): + redacted = dict(WIFI) + del redacted["ssid"] + self.assertEqual(nm.connection_name(redacted), "Wi-Fi") + self.assertEqual(bytes(nm.settings(redacted)["802-11-wireless"]["ssid"].unpack()), b"Wi-Fi") + for transport, kind, device_type in (("ethernet", "802-3-ethernet", 1), ("vpn", "tun", 16)): + state = dict(WIFI, transport=transport) + found = nm.objects(state) + self.assertEqual(found[nm.ROOT][nm.NAME]["PrimaryConnectionType"].unpack(), kind) + self.assertEqual(found[nm.device_path(state)][nm.NAME + ".Device"]["DeviceType"].unpack(), device_type) + self.assertNotIn(nm.AP, found) + + +@unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject") +class NetworkBusTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.snapshot = Path(self.temp.name) / "network.json" + self.write(WIFI) + self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + env = dict(os.environ, DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"]) + # Use the production service and only redirect its input file for the fixture. + launch = "import runpy,sys; from pathlib import Path; n=runpy.run_path(sys.argv[1]); n['main'].__globals__['SNAPSHOT']=Path(sys.argv[2]); n['main']()" + self.process = subprocess.Popen([sys.executable, "-c", launch, str(SCRIPT), str(self.snapshot)], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.await_type("802-11-wireless") + + def tearDown(self): + self.process.terminate() + _, stderr = self.process.communicate(timeout=5) + self.temp.cleanup() + self.assertEqual(stderr, b"", stderr.decode()) + + def write(self, state): + staged = self.snapshot.with_suffix(".staged") + staged.write_text(json.dumps(state)) + staged.replace(self.snapshot) + + def call(self, path, interface, method, parameters=None): + return self.bus.call_sync(nm.NAME, path, interface, method, parameters, None, + Gio.DBusCallFlags.NONE, 1000, None).unpack() + + def prop(self, path, interface, name): + return self.call(path, nm.PROPERTIES, "Get", GLib.Variant("(ss)", (interface, name)))[0] + + def await_type(self, expected): + deadline = time.monotonic() + 6 + while time.monotonic() < deadline: + if self.process.poll() is not None: + self.fail(self.process.communicate()[1].decode()) + try: + if self.prop(nm.ROOT, nm.NAME, "PrimaryConnectionType") == expected: + return + except GLib.Error: + pass + time.sleep(0.05) + self.fail("network type did not change to " + expected) + + def test_direct_properties_methods_and_handover(self): + signals = [] + subscription = self.bus.signal_subscribe( + nm.NAME, None, None, None, None, Gio.DBusSignalFlags.NONE, + lambda bus, sender, path, interface, member, args: signals.append((path, interface, member, args.unpack()))) + self.addCleanup(self.bus.signal_unsubscribe, subscription) + wifi_path = nm.device_path(WIFI) + self.assertEqual(self.prop(wifi_path, nm.NAME + ".Device", "DeviceType"), 2) + self.assertEqual(self.prop(nm.AP, nm.NAME + ".AccessPoint", "Strength"), 73) + self.assertEqual(self.call(wifi_path, nm.NAME + ".Device.Wireless", "GetAccessPoints"), ([nm.AP],)) + self.assertEqual(self.call(wifi_path, nm.NAME + ".Device.Wireless", "RequestScan", GLib.Variant("(a{sv})", ({},))), ()) + all_props = self.call(nm.ROOT, nm.PROPERTIES, "GetAll", GLib.Variant("(s)", (nm.NAME,)))[0] + self.assertTrue(all_props["WirelessEnabled"]) + introspection = self.call(wifi_path, "org.freedesktop.DBus.Introspectable", "Introspect")[0] + self.assertIn("GetAllAccessPoints", introspection) + self.write(dict(WIFI, strength=21, ssid="Other Wi-Fi")) + deadline = time.monotonic() + 4 + while self.prop(nm.AP, nm.NAME + ".AccessPoint", "Strength") != 21 and time.monotonic() < deadline: + time.sleep(0.05) + self.assertEqual(self.prop(nm.AP, nm.NAME + ".AccessPoint", "Strength"), 21) + self.assertEqual(self.call(nm.PROFILE, nm.NAME + ".Settings.Connection", "GetSettings")[0]["connection"]["id"], "Other Wi-Fi") + self.write(CELLULAR) + self.await_type("gsm") + cell_path = nm.device_path(CELLULAR) + self.assertEqual(self.call(nm.ROOT, nm.NAME, "GetDevices"), ([cell_path],)) + self.assertEqual(self.prop(cell_path, nm.NAME + ".Device", "DeviceType"), 8) + self.assertEqual(self.prop(nm.ROOT, nm.NAME, "Metered"), 1) + managed = self.call(nm.ROOT, nm.OBJECT_MANAGER, "GetManagedObjects")[0] + self.assertNotIn(wifi_path, managed) + self.assertNotIn(nm.AP, managed) + self.assertNotIn(nm.IP4, managed) + self.assertIn(nm.IP6, managed) + with self.assertRaises(GLib.Error): + self.prop(wifi_path, nm.NAME + ".Device", "DeviceType") + self.write(OFFLINE) + self.await_type("") + self.assertEqual(self.call(nm.ROOT, nm.NAME, "CheckConnectivity"), (1,)) + self.write(WIFI) + self.await_type("802-11-wireless") + self.assertEqual(self.prop(nm.AP, nm.NAME + ".AccessPoint", "Strength"), 73) + context = GLib.MainContext.default() + while context.pending(): + context.iteration(False) + self.assertTrue(any(interface == nm.PROPERTIES and member == "PropertiesChanged" and + args[1].get("PrimaryConnectionType") == "gsm" + for _, interface, member, args in signals)) + self.assertTrue(any(interface == nm.OBJECT_MANAGER and member == "InterfacesRemoved" and args[0] == wifi_path + for _, interface, member, args in signals)) + self.assertTrue(any(member == "AccessPointAdded" for _, _, member, _ in signals)) + # A missing/invalid snapshot is a transient input error, never a fake Ethernet handover. + self.snapshot.write_text("{") + time.sleep(1.2) + self.assertEqual(self.prop(nm.ROOT, nm.NAME, "PrimaryConnectionType"), "802-11-wireless") + + def test_libnm_client(self): + try: + gi.require_version("NM", "1.0") + from gi.repository import NM + except ValueError: + self.skipTest("requires gir1.2-nm-1.0") + # libnm is a real consumer of the same API Steam reads. It caches objects by path, + # so the handover must replace the Wi-Fi device with a modem rather than mutate its type. + previous = os.environ.get("DBUS_SYSTEM_BUS_ADDRESS") + os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = os.environ["DBUS_SESSION_BUS_ADDRESS"] + try: + client = NM.Client.new(None) + self.assertTrue(client.get_nm_running()) + wifi = client.get_devices()[0] + self.assertIsInstance(wifi, NM.DeviceWifi) + self.assertEqual(wifi.get_active_access_point().get_strength(), 73) + self.assertEqual(client.get_primary_connection().get_connection_type(), "802-11-wireless") + self.write(CELLULAR) + self.await_type("gsm") + context = GLib.MainContext.default() + deadline = time.monotonic() + 4 + while time.monotonic() < deadline: + while context.pending(): + context.iteration(False) + if client.get_devices() and isinstance(client.get_devices()[0], NM.DeviceModem): + break + time.sleep(0.05) + self.assertIsInstance(client.get_devices()[0], NM.DeviceModem) + self.assertEqual(client.get_primary_connection().get_connection_type(), "gsm") + finally: + if previous is None: + os.environ.pop("DBUS_SYSTEM_BUS_ADDRESS", None) + else: + os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = previous + + +if __name__ == "__main__": + unittest.main() From 5614621d6e11b958afde0c7b5b7988740235fdbe Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 2 Oct 2026 15:39:49 -0400 Subject: [PATCH 2/3] Expose Android Wi-Fi names and nearby networks to Steam --- app/src/main/AndroidManifest.xml | 4 + .../runtime/LinuxNetworkLinkComponent.kt | 87 +++++++++++- .../usr/local/bin/bannerlator-netmanager | 124 ++++++++++++++---- tools/tests/test_netmanager.py | 55 +++++++- 4 files changed, 238 insertions(+), 32 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b27afa720..27c3dc0e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,10 @@ download from being throttled to nothing by the radio's power-save. --> + + + + - + diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e270a3344..af023ca79 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -47,6 +47,7 @@ import com.droiddeck.launcher.core.CpuCores import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.core.WirelessAdbFix +import com.droiddeck.launcher.core.WifiDiscovery import com.droiddeck.launcher.core.WirelessAdbPairingService import com.droiddeck.launcher.ui.CoreRow import com.droiddeck.launcher.ui.PerformancePage @@ -149,6 +150,24 @@ class MainActivity : ComponentActivity() { private var launcherFullscreen by mutableStateOf(true) private var storeEnabled by mutableStateOf(false) private var mic by mutableStateOf(false) + private var wifiDiscovery by mutableStateOf(false) + private var wifiDiscoveryPermission by mutableStateOf(false) + private var wifiDiscoveryLocation by mutableStateOf(false) + private var wifiDiscoveryAsked by mutableStateOf(false) + private var wifiDiscoveryBlocked by mutableStateOf(false) + private val wifiLocationReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context?, intent: Intent?) { + refreshWifiDiscovery() + } + } + private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this)) + refreshWifiDiscovery() + } + private val wifiPermissionSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this)) + refreshWifiDiscovery() + } // The app's own picker (files/), once per kind of pick: the two driver lists validate // differently, and the reason a zip is refused names the list it belongs in. @@ -176,6 +195,28 @@ class MainActivity : ComponentActivity() { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) } + private fun refreshWifiDiscovery() { + wifiDiscoveryPermission = WifiDiscovery.permissionGranted(this) + wifiDiscoveryLocation = WifiDiscovery.locationEnabled(this) + wifiDiscoveryAsked = SessionPrefs.wifiDiscoveryAsked(this) + wifiDiscoveryBlocked = !wifiDiscoveryPermission && wifiDiscoveryAsked && + WifiDiscovery.permissions.none { shouldShowRequestPermissionRationale(it) } + // A revoked grant must not leave an enabled switch behind. + if (!wifiDiscoveryPermission && SessionPrefs.wifiDiscoveryEnabled(this)) { + SessionPrefs.setWifiDiscoveryEnabled(this, false) + } + wifiDiscovery = SessionPrefs.wifiDiscoveryEnabled(this) + } + + private fun openWifiDiscoverySettings() { + if (!WifiDiscovery.permissionGranted(this)) { + wifiPermissionSettings.launch(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$packageName"))) + } else { + startActivity(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + } + } + /** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */ private fun syncLossless() { Thread({ @@ -680,6 +721,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + refreshWifiDiscovery() com.droiddeck.launcher.ui.Motion.refresh(this) // Back from a session stopped behind a flood: open on its blue, before the first frame. com.droiddeck.launcher.ui.QuitFlood.take()?.let { c -> @@ -717,6 +759,7 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() + registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION)) displayManager.registerDisplayListener(secondScreenDisplayListener, ui) refreshSecondScreenDisplays() } @@ -727,6 +770,7 @@ class MainActivity : ComponentActivity() { } override fun onStop() { + unregisterReceiver(wifiLocationReceiver) displayManager.unregisterDisplayListener(secondScreenDisplayListener) // The session covers the page by now; coming back finds it as it was. flood = null @@ -963,6 +1007,11 @@ class MainActivity : ComponentActivity() { mangoapp = mangoapp, steamController = if (mode == SessionService.MODE_STEAM) steamController else null, runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup, + wifiDiscovery = if (mode == SessionService.MODE_STEAM) wifiDiscovery else null, + wifiDiscoveryPermission = wifiDiscoveryPermission, + wifiDiscoveryLocation = wifiDiscoveryLocation, + wifiDiscoveryAsked = wifiDiscoveryAsked, + wifiDiscoveryBlocked = wifiDiscoveryBlocked, addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), addedGamesArt = addedGamesArt, @@ -972,6 +1021,21 @@ class MainActivity : ComponentActivity() { deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running, ), ModeSettingsActions( + onWifiDiscovery = { on -> + if (!on) { + SessionPrefs.setWifiDiscoveryEnabled(this, false) + refreshWifiDiscovery() + } else if (WifiDiscovery.permissionGranted(this)) { + SessionPrefs.setWifiDiscoveryEnabled(this, true) + refreshWifiDiscovery() + } else if (wifiDiscoveryBlocked) { + openWifiDiscoverySettings() + } else { + SessionPrefs.setWifiDiscoveryAsked(this) + wifiLocationPermission.launch(WifiDiscovery.permissions) + } + }, + onWifiDiscoverySettings = { openWifiDiscoverySettings() }, onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap }, onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size }, onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, @@ -1126,6 +1190,7 @@ class MainActivity : ComponentActivity() { forceFullscreen = SessionPrefs.forceFullscreen(this) stretch16x9 = SessionPrefs.stretch16x9(this) mic = SessionPrefs.micEnabled(this) + refreshWifiDiscovery() renderer = SessionPrefs.desktopRenderer(this) gameStorage = SessionPrefs.gameStorage(this) settingsMode = mode diff --git a/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt b/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt new file mode 100644 index 000000000..40a6721dc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt @@ -0,0 +1,24 @@ +package com.droiddeck.launcher.core + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import android.os.Build +import com.droiddeck.launcher.session.SessionPrefs + +/** Location is used only for Android's Wi-Fi names and scan results, never coordinates. */ +object WifiDiscovery { + val permissions = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) + + // targetSdk 28 accepts either grant for Wi-Fi scanning. + fun permissionGranted(context: Context): Boolean = permissions.any { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + fun locationEnabled(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.P || + (context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager)?.isLocationEnabled == true + + fun available(context: Context): Boolean = SessionPrefs.wifiDiscoveryEnabled(context) && + permissionGranted(context) && locationEnabled(context) +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt index 04ac1f9dc..a47ca9330 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -1,11 +1,9 @@ package com.droiddeck.launcher.runtime -import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageManager import android.location.LocationManager import android.net.ConnectivityManager import android.net.LinkProperties @@ -20,6 +18,7 @@ import android.os.SystemClock import android.provider.Settings import android.util.Log import com.droiddeck.launcher.core.SessionPart +import com.droiddeck.launcher.core.WifiDiscovery import java.io.File import java.io.IOException import java.net.Inet4Address @@ -56,8 +55,13 @@ class LinuxNetworkLinkComponent( private var wifiReceiver: BroadcastReceiver? = null private var scanRequestStamp = 0L private var lastScanRequest = -SCAN_INTERVAL_MS + @Volatile private var lastNamesAllowed = false private val scanRequests = object : Runnable { override fun run() { + if (canReadWifiNames() != lastNamesAllowed) { + synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) } + requestWifiScan() + } val stamp = File(rootDir, "etc/bannerlator-wifi-scan-request").lastModified() if (stamp != 0L && stamp != scanRequestStamp) { scanRequestStamp = stamp @@ -139,12 +143,7 @@ class LinuxNetworkLinkComponent( } } - private fun canReadWifiNames(): Boolean { - val allowed = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) - .any { appContext.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } - val location = appContext.getSystemService(Context.LOCATION_SERVICE) as? LocationManager - return allowed && (Build.VERSION.SDK_INT < Build.VERSION_CODES.P || location?.isLocationEnabled == true) - } + private fun canReadWifiNames(): Boolean = WifiDiscovery.available(appContext) @Suppress("DEPRECATION") private fun requestWifiScan() { @@ -179,6 +178,7 @@ class LinuxNetworkLinkComponent( @Suppress("DEPRECATION") private fun writeNetworkState(properties: LinkProperties?, capabilities: NetworkCapabilities?) { val namesAllowed = canReadWifiNames() + lastNamesAllowed = namesAllowed val scans = if (namesAllowed && wifiManager?.isWifiEnabled == true) { try { wifiManager.scanResults.orEmpty() } catch (e: SecurityException) { emptyList() } } else emptyList() @@ -231,18 +231,21 @@ class LinuxNetworkLinkComponent( } }) if (transport == "wifi") { + val connectionInfo = try { wifiManager?.connectionInfo } catch (e: SecurityException) { null } val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - (capabilities?.transportInfo as? WifiInfo) ?: wifiManager?.connectionInfo + (capabilities?.transportInfo as? WifiInfo) ?: connectionInfo } else { - wifiManager?.connectionInfo + connectionInfo } if (info != null) { // SSIDs can be redacted by Android. No location permission is needed to // report the transport; a hidden name simply displays as Wi-Fi in Steam. - val namedInfo = if (namesAllowed) wifiManager?.connectionInfo ?: info else info - namedInfo.ssid?.takeUnless { it == WifiManager.UNKNOWN_SSID || it.isEmpty() } - ?.removeSurrounding("\"")?.let { put("ssid", it) } - namedInfo.bssid?.takeUnless { it == "02:00:00:00:00:00" }?.let { put("bssid", it) } + if (namesAllowed) { + val namedInfo = connectionInfo ?: info + namedInfo.ssid?.takeUnless { it == WifiManager.UNKNOWN_SSID || it.isEmpty() } + ?.removeSurrounding("\"")?.let { put("ssid", it) } + namedInfo.bssid?.takeUnless { it == "02:00:00:00:00:00" }?.let { put("bssid", it) } + } put("strength", WifiManager.calculateSignalLevel(info.rssi, 101)) put("frequency", info.frequency.coerceAtLeast(0)) put("bitrate", info.linkSpeed.coerceAtLeast(0) * 1000) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 9a6c12e09..0bfe9c9d0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -220,6 +220,19 @@ object SessionPrefs { prefs(context).edit().putBoolean("mic", on).apply() } + /** Optional SSID reporting and scans. A Location grant alone never opts the user in. */ + fun wifiDiscoveryEnabled(context: Context): Boolean = prefs(context).getBoolean("wifiDiscovery", false) + + fun setWifiDiscoveryEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("wifiDiscovery", on).apply() + } + + fun wifiDiscoveryAsked(context: Context): Boolean = prefs(context).getBoolean("wifiDiscoveryAsked", false) + + fun setWifiDiscoveryAsked(context: Context) { + prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply() + } + /** * Whether the client's own core pick is overridden. When on, BL_CLIENT_CPUS is sent even when * it names every core - unlike a game mask, the point here is to undo a pin Steam applies to diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 61f1e2fc0..9d8a4009a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -80,6 +80,12 @@ class ModeSettings( val steamController: String? = null, /** Steam only: start a Steam session when DroidDeck opens. */ val runSteamAtStartup: Boolean = false, + /** Null outside Steam; the saved choice is separate from Android's access and device switch. */ + val wifiDiscovery: Boolean? = null, + val wifiDiscoveryPermission: Boolean = false, + val wifiDiscoveryLocation: Boolean = false, + val wifiDiscoveryAsked: Boolean = false, + val wifiDiscoveryBlocked: Boolean = false, /** Steam only: the user's chosen Games folders; null outside Steam. */ val addedGamesDirs: List? = null, val addedGames: List = emptyList(), @@ -125,6 +131,8 @@ class ModeSettingsActions( val onMangoapp: (Boolean) -> Unit = {}, val onSteamController: (String) -> Unit = {}, val onRunSteamAtStartup: (Boolean) -> Unit = {}, + val onWifiDiscovery: (Boolean) -> Unit = {}, + val onWifiDiscoverySettings: () -> Unit = {}, val onPickAddedGamesDir: () -> Unit = {}, val onForgetAddedGamesDir: (path: String) -> Unit = {}, val onAddedGamesArt: (Boolean) -> Unit = {}, @@ -142,6 +150,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { val steam = s.mode == SessionService.MODE_STEAM val host = rememberMenuHost() var confirmDeckyRemoval by remember { mutableStateOf(false) } + var explainWifiDiscovery by remember { mutableStateOf(false) } val pageScroll = androidx.compose.foundation.rememberScrollState() val firstChip = remember { androidx.compose.ui.focus.FocusRequester() } androidx.compose.runtime.LaunchedEffect(Unit) { @@ -326,6 +335,25 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { onPick = a.onSteamChannel, ) } + if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { + val hint = when { + s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off) + !s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied) + else -> stringResource(R.string.mode_wifi_discovery_hint) + } + SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) { + if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) { + SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings) + } + ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on -> + host.open = null + if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false) + } + } + } + } if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) { for (dir in s.addedGamesDirs) { val n = s.addedGames.count { it.folderPath.startsWith("$dir/") } @@ -418,6 +446,19 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } + if (explainWifiDiscovery) AlertDialog( + onDismissRequest = { explainWifiDiscovery = false }, + title = { Text(stringResource(R.string.mode_wifi_explain_title)) }, + text = { Text(stringResource(R.string.mode_wifi_explain_text)) }, + confirmButton = { + TextButton(onClick = { explainWifiDiscovery = false; a.onWifiDiscovery(true) }) { + Text(stringResource(if (s.wifiDiscoveryBlocked) R.string.mode_wifi_open_settings else R.string.mode_wifi_continue)) + } + }, + dismissButton = { + TextButton(onClick = { explainWifiDiscovery = false }) { Text(stringResource(R.string.common_cancel)) } + }, + ) if (confirmDeckyRemoval) AlertDialog( onDismissRequest = { confirmDeckyRemoval = false }, title = { Text(stringResource(R.string.mode_decky_remove_title)) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 618b44821..61200faa6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -292,6 +292,15 @@ DirectAudio Microphone For voice chat. + Network + Wi-Fi discovery + Show Wi-Fi names and nearby networks in Steam. + Location services are off. + Location permission not granted. + Open settings + Continue + Enable Wi-Fi discovery? + Android requires Location permission to read Wi-Fi names and discover nearby networks. DroidDeck uses this access to show those networks in Steam. We don\'t track your location. Internet access works without this permission. Game storage Automatic - the SD card when present Automatic - the SD card when present (none now)