Merge pull request #150 from Droid-Deck/feat/steam-native-network

Show Android Wi-Fi and cellular networks in Steam BPM
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-02 16:16:46 -04:00
commit 3404bbc128
10 files changed
+1002 -181

No files matched your search

+4
View File
@@ -13,6 +13,10 @@
download from being throttled to nothing by the radio's power-save. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Optional Wi-Fi discovery: requested only from Steam settings after an explanation. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Ends background processes of another app that holds the Steam login. GameHub starts
itself with the phone via boot receivers and signs this client out seconds after every
@@ -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
@@ -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")
}
}
@@ -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)
}
@@ -1,17 +1,30 @@
package com.droiddeck.launcher.runtime
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
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.os.Handler
import android.os.Looper
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
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,20 +47,94 @@ 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
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
private val scanHandler = Handler(Looper.getMainLooper())
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
requestWifiScan()
}
if (wifiReceiver != null) scanHandler.postDelayed(this, 1000)
}
}
/** 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)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
}
}
wifiReceiver = receiver
appContext.registerReceiver(receiver, IntentFilter().apply {
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
addAction(WifiManager.RSSI_CHANGED_ACTION)
addAction(WifiManager.WIFI_STATE_CHANGED_ACTION)
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
addAction(LocationManager.MODE_CHANGED_ACTION)
})
scanRequestStamp = File(rootDir, "etc/bannerlator-wifi-scan-request").lastModified()
scanHandler.post(scanRequests)
requestWifiScan()
}
override fun stop() {
scanHandler.removeCallbacks(scanRequests)
wifiReceiver?.let { appContext.unregisterReceiver(it) }
wifiReceiver = null
val registered = synchronized(lock) { callback.also { callback = null } } ?: return
try {
connectivity.unregisterNetworkCallback(registered)
@@ -56,7 +143,22 @@ class LinuxNetworkLinkComponent(
}
}
private fun write(properties: LinkProperties?) {
private fun canReadWifiNames(): Boolean = WifiDiscovery.available(appContext)
@Suppress("DEPRECATION")
private fun requestWifiScan() {
val now = SystemClock.elapsedRealtime()
if (!canReadWifiNames() || wifiManager?.isWifiEnabled != true || now - lastScanRequest < SCAN_INTERVAL_MS) return
lastScanRequest = now
try {
Log.i(TAG, "Wi-Fi scan requested: accepted=${wifiManager.startScan()}")
} catch (e: SecurityException) {
Log.w(TAG, "Wi-Fi scan permission unavailable")
}
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
}
private fun write(properties: LinkProperties?, capabilities: NetworkCapabilities?) {
val file = File(rootDir, LINK_FILE)
synchronized(lock) {
try {
@@ -67,10 +169,99 @@ 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 namesAllowed = canReadWifiNames()
lastNamesAllowed = namesAllowed
val scans = if (namesAllowed && wifiManager?.isWifiEnabled == true) {
try { wifiManager.scanResults.orEmpty() } catch (e: SecurityException) { emptyList() }
} else emptyList()
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("scanAllowed", namesAllowed)
put("lastScan", scans.maxOfOrNull { it.timestamp / 1000 } ?: -1L)
put("accessPoints", JSONArray().apply {
scans.filter { it.SSID.isNotEmpty() }.forEach {
put(JSONObject().apply {
put("ssid", it.SSID)
put("bssid", it.BSSID)
put("strength", WifiManager.calculateSignalLevel(it.level, 101))
put("frequency", it.frequency)
put("capabilities", it.capabilities)
put("lastSeen", it.timestamp / 1_000_000)
})
}
})
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 connectionInfo = try { wifiManager?.connectionInfo } catch (e: SecurityException) { null }
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
(capabilities?.transportInfo as? WifiInfo) ?: connectionInfo
} else {
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.
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)
}
}
}
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
@@ -149,5 +340,6 @@ class LinuxNetworkLinkComponent(
private const val DEFAULT_MTU = 1500
private const val OFFLINE_NAME = "eth0"
private const val OFFLINE_ADDRESS = "10.0.0.2 24"
private const val SCAN_INTERVAL_MS = 30_000L
}
}
@@ -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
@@ -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<String>? = null,
val addedGames: List<AddedGameRow> = 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)) },
+9
View File
@@ -292,6 +292,15 @@
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Microphone</string>
<string name="mode_mic_hint">For voice chat.</string>
<string name="mode_network">Network</string>
<string name="mode_wifi_discovery">Wi-Fi discovery</string>
<string name="mode_wifi_discovery_hint">Show Wi-Fi names and nearby networks in Steam.</string>
<string name="mode_wifi_location_off">Location services are off.</string>
<string name="mode_wifi_permission_denied">Location permission not granted.</string>
<string name="mode_wifi_open_settings">Open settings</string>
<string name="mode_wifi_continue">Continue</string>
<string name="mode_wifi_explain_title">Enable Wi-Fi discovery?</string>
<string name="mode_wifi_explain_text">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.</string>
<string name="mode_storage">Game storage</string>
<string name="mode_storage_auto">Automatic - the SD card when present</string>
<string name="mode_storage_auto_none">Automatic - the SD card when present (none now)</string>
@@ -1,20 +1,17 @@
#!/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 nearby Wi-Fi access points. Requests to
# scan are handed to Android; Android settings still handle joining networks.
import ipaddress
import json
import re
import time
import socket
import struct
import sys
from pathlib import Path
from xml.sax.saxutils import escape
import gi
@@ -24,207 +21,401 @@ 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")
SCAN_REQUEST = Path("/etc/bannerlator-wifi-scan-request")
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 = """
<node>
<interface name="org.freedesktop.DBus.ObjectManager">
<method name="GetManagedObjects">
<arg type="a{oa{sa{sv}}}" direction="out"/>
</method>
</interface>
<interface name="org.freedesktop.NetworkManager">
<method name="GetPermissions"><arg type="a{ss}" direction="out"/></method>
<method name="CheckConnectivity"><arg type="u" direction="out"/></method>
<method name="GetDevices"><arg type="ao" direction="out"/></method>
<method name="GetAllDevices"><arg type="ao" direction="out"/></method>
<signal name="StateChanged"><arg type="u"/></signal>
</interface>
<interface name="org.freedesktop.NetworkManager.Settings.Connection">
<method name="GetSettings"><arg type="a{sa{sv}}" direction="out"/></method>
</interface>
<interface name="org.freedesktop.NetworkManager.Device">
<signal name="StateChanged"><arg type="u"/><arg type="u"/><arg type="u"/></signal>
</interface>
</node>
"""
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: '''
<method name="GetManagedObjects"><arg type="a{oa{sa{sv}}}" direction="out"/></method>
<signal name="InterfacesAdded"><arg type="o"/><arg type="a{sa{sv}}"/></signal>
<signal name="InterfacesRemoved"><arg type="o"/><arg type="as"/></signal>''',
NAME: '''
<method name="GetPermissions"><arg type="a{ss}" direction="out"/></method>
<method name="CheckConnectivity"><arg type="u" direction="out"/></method>
<method name="GetDevices"><arg type="ao" direction="out"/></method>
<method name="GetAllDevices"><arg type="ao" direction="out"/></method>
<signal name="StateChanged"><arg type="u"/></signal>''',
NAME + ".Settings": '<method name="ListConnections"><arg type="ao" direction="out"/></method>',
NAME + ".Settings.Connection": '<method name="GetSettings"><arg type="a{sa{sv}}" direction="out"/></method><signal name="Updated"/>',
NAME + ".Device": '<signal name="StateChanged"><arg type="u"/><arg type="u"/><arg type="u"/></signal>',
NAME + ".Device.Wireless": '''
<method name="GetAccessPoints"><arg type="ao" direction="out"/></method>
<method name="GetAllAccessPoints"><arg type="ao" direction="out"/></method>
<method name="RequestScan"><arg type="a{sv}" direction="in"/></method>
<signal name="AccessPointAdded"><arg type="o"/></signal>
<signal name="AccessPointRemoved"><arg type="o"/></signal>''',
}
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 ap_path(bssid):
if bssid and re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", bssid):
return ROOT + "/AccessPoint/" + bssid.replace(":", "").lower()
return AP
def active_ap(state):
return ap_path(state.get("bssid")) if state["connected"] and state.get("transport") == "wifi" else "/"
def ap_security(capabilities):
# NetworkManager's cipher/key-management flags, from Android's beacon capabilities.
wpa = rsn = 0
for group in re.findall(r"\[([^]]+)\]", capabilities.upper()):
flags = 0
if "CCMP" in group:
flags |= 0x08 | 0x80
if "TKIP" in group:
flags |= 0x04 | 0x40
if "PSK" in group:
flags |= 0x100
if "EAP" in group:
flags |= 0x200
if "SAE" in group:
flags |= 0x400
if "OWE" in group:
flags |= 0x800
if "WPA2" in group or "RSN" in group or "SAE" in group or "OWE" in group:
rsn |= flags
elif "WPA" in group:
wpa |= flags
privacy = int(bool(wpa or rsn or "WEP" in capabilities.upper()))
return privacy, wpa, rsn
def access_points(state):
result = {ap_path(ap.get("bssid")): dict(ap) for ap in state.get("accessPoints", []) if ap.get("ssid")}
if active_ap(state) != "/":
current = result.setdefault(active_ap(state), {})
for key in ("ssid", "bssid", "strength", "frequency", "bitrate"):
if key in state:
current[key] = state[key]
current.setdefault("ssid", "Wi-Fi")
return result
def ap_properties(ap):
privacy, wpa, rsn = ap_security(ap.get("capabilities", ""))
return {
"Flags": v("u", privacy), "WpaFlags": v("u", wpa), "RsnFlags": v("u", rsn),
"Ssid": v("ay", list(ap["ssid"].encode("utf-8"))),
"Frequency": v("u", ap.get("frequency", 0)), "HwAddress": v("s", ap.get("bssid", "")), "Mode": v("u", 2),
"MaxBitrate": v("u", ap.get("bitrate", 0)),
"Strength": v("y", max(0, min(100, ap.get("strength", 0)))), "LastSeen": v("i", ap.get("lastSeen", -1)),
}
def objects(state):
device = device_path(state)
aps = access_points(state)
current_ap = active_ap(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", list(aps)), "ActiveAccessPoint": v("o", current_ap),
"LastScan": v("x", state.get("lastScan", -1)),
}
for path, ap in aps.items():
found[path] = {NAME + ".AccessPoint": ap_properties(ap)}
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", current_ap),
"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)}
if up and not wifi and state.get("wifiEnabled"):
# Android can keep its Wi-Fi radio available while cellular/VPN is the default route.
wireless = objects(dict(state, connected=False, transport="none",
interface=state.get("wifiInterface", "wlan0"), metered=None))
wifi_device = ROOT + "/Devices/Wifi"
found[wifi_device] = wireless[wifi_device]
found[wifi_device][NAME + ".Device"]["AvailableConnections"] = v("ao", [])
for path, interfaces in wireless.items():
if NAME + ".AccessPoint" in interfaces:
found[path] = interfaces
found[ROOT][NAME]["Devices"] = v("ao", [device, wifi_device])
found[ROOT][NAME]["AllDevices"] = v("ao", [device, wifi_device])
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'<property name="{escape(key)}" type="{value.get_type_string()}" access="read"/>'
for key, value in values.items())
node = Gio.DBusNodeInfo.new_for_xml(f'<node><interface name="{interface}">{METHODS.get(interface, "")}{props}</interface></node>')
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)", (self.objects[path][NAME + ".Device.Wireless"]["AccessPoints"].unpack(),)))
elif method == "RequestScan":
if self.state.get("scanAllowed") is False:
invocation.return_dbus_error(NAME + ".Device.NotAllowed", "Wi-Fi discovery requires Android Location permission and enabled Location services")
return
try:
SCAN_REQUEST.write_text(str(time.monotonic_ns()))
except OSError:
invocation.return_dbus_error(NAME + ".Device.Failed", "Could not request an Android Wi-Fi scan")
return
self.check()
invocation.return_value(v("()", ()))
elif method in ("GetDevices", "GetAllDevices"):
invocation.return_value(v("(ao)", (self.objects[ROOT][NAME]["Devices"].unpack(),)))
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, [])))
wifi_device = ROOT + "/Devices/Wifi"
wireless = NAME + ".Device.Wireless"
old_aps = before.get(wifi_device, {}).get(wireless, {}).get("AccessPoints", v("ao", [])).unpack()
new_aps = after.get(wifi_device, {}).get(wireless, {}).get("AccessPoints", v("ao", [])).unpack()
if wifi_device in after:
for path in set(old_aps) - set(new_aps):
self.bus.emit_signal(None, wifi_device, wireless, "AccessPointRemoved", v("(o)", (path,)))
for path in set(new_aps) - set(old_aps):
self.bus.emit_signal(None, wifi_device, wireless, "AccessPointAdded", v("(o)", (path,)))
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():
+284
View File
@@ -0,0 +1,284 @@
"""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)
def test_named_current_and_nearby_access_points(self):
current = {"ssid": "Current Wi-Fi", "bssid": "00:11:22:33:44:55", "strength": 75,
"capabilities": "[WPA2-PSK-CCMP][RSN-PSK+SAE-CCMP][ESS]"}
other = {"ssid": "Other Wi-Fi", "bssid": "00:11:22:33:44:66", "strength": 42,
"capabilities": "[ESS]"}
state = dict(WIFI, ssid=current["ssid"], bssid=current["bssid"], accessPoints=[current, other], lastScan=123456)
found = nm.objects(state)
wireless = found[nm.device_path(state)][nm.NAME + ".Device.Wireless"]
self.assertEqual(wireless["ActiveAccessPoint"].unpack(), nm.ap_path(current["bssid"]))
self.assertEqual(set(wireless["AccessPoints"].unpack()), {nm.ap_path(current["bssid"]), nm.ap_path(other["bssid"])})
self.assertEqual(wireless["LastScan"].unpack(), 123456)
secured = found[nm.ap_path(current["bssid"])][nm.NAME + ".AccessPoint"]
self.assertEqual(secured["Flags"].unpack(), 1)
self.assertTrue(secured["RsnFlags"].unpack() & 0x100)
self.assertTrue(secured["RsnFlags"].unpack() & 0x400)
self.assertEqual(found[nm.ap_path(other["bssid"])][nm.NAME + ".AccessPoint"]["Flags"].unpack(), 0)
# Keep nearby-network discovery while a cellular connection provides the default route.
cellular = nm.objects(dict(CELLULAR, accessPoints=[other]))
self.assertIn(nm.ap_path(other["bssid"]), cellular)
self.assertEqual(cellular[nm.ROOT + "/Devices/Wifi"][nm.NAME + ".Device.Wireless"]["ActiveAccessPoint"].unpack(), "/")
@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]); g=n['main'].__globals__; g['SNAPSHOT']=Path(sys.argv[2]); g['SCAN_REQUEST']=Path(sys.argv[2]).with_suffix('.request'); 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, nm.ROOT + "/Devices/Wifi"],))
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.assertIn(wifi_path, managed)
self.assertEqual(managed[wifi_path][nm.NAME + ".Device"]["State"], 30)
self.assertNotIn(nm.AP, managed)
self.assertNotIn(nm.IP4, managed)
self.assertIn(nm.IP6, managed)
self.assertEqual(self.prop(wifi_path, nm.NAME + ".Device.Wireless", "ActiveAccessPoint"), "/")
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] == nm.AP
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_scan_requests_and_live_results(self):
wifi_path = nm.device_path(WIFI)
self.call(wifi_path, nm.NAME + ".Device.Wireless", "RequestScan", GLib.Variant("(a{sv})", ({},)))
self.assertTrue(self.snapshot.with_suffix(".request").is_file())
current = {"ssid": "Named Current", "bssid": "00:11:22:33:44:55", "capabilities": "[WPA2-PSK-CCMP]"}
other = {"ssid": "Nearby", "bssid": "00:11:22:33:44:66", "strength": 37, "capabilities": "[ESS]"}
self.write(dict(WIFI, ssid=current["ssid"], bssid=current["bssid"], accessPoints=[current, other], scanAllowed=True))
deadline = time.monotonic() + 4
while time.monotonic() < deadline:
aps = self.call(wifi_path, nm.NAME + ".Device.Wireless", "GetAllAccessPoints")[0]
if nm.ap_path(other["bssid"]) in aps:
break
time.sleep(0.05)
self.assertIn(nm.ap_path(other["bssid"]), aps)
self.assertEqual(self.prop(wifi_path, nm.NAME + ".Device.Wireless", "ActiveAccessPoint"), nm.ap_path(current["bssid"]))
self.assertEqual(bytes(self.prop(nm.ap_path(other["bssid"]), nm.NAME + ".AccessPoint", "Ssid")).decode(), "Nearby")
self.assertEqual(self.call(nm.PROFILE, nm.NAME + ".Settings.Connection", "GetSettings")[0]["connection"]["id"], "Named Current")
self.write(dict(WIFI, scanAllowed=False))
time.sleep(1.2)
with self.assertRaises(GLib.Error):
self.call(wifi_path, nm.NAME + ".Device.Wireless", "RequestScan", GLib.Variant("(a{sv})", ({},)))
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()