mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
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:
10 files changed
+1002
-181
No files matched your search
@@ -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)) },
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user