diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 286a9ee8a..229aad7d3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,6 +45,8 @@ android:isGame="true"> + + diff --git a/app/src/main/java/com/droiddeck/launcher/lan/LocalHotspotService.kt b/app/src/main/java/com/droiddeck/launcher/lan/LocalHotspotService.kt new file mode 100644 index 000000000..3580e6cbd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/lan/LocalHotspotService.kt @@ -0,0 +1,127 @@ +package com.droiddeck.launcher.lan + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import com.droiddeck.launcher.R + +class LocalHotspotService : Service() { + private var reservation: WifiManager.LocalOnlyHotspotReservation? = null + private var requesting = false + private var alive = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + alive = true + val notifications = getSystemService(NotificationManager::class.java) + notifications.createNotificationChannel(NotificationChannel(CHANNEL, getString(R.string.lan_title), NotificationManager.IMPORTANCE_LOW)) + val open = PendingIntent.getActivity(this, 0, Intent(this, LocalLanActivity::class.java), PendingIntent.FLAG_IMMUTABLE) + val stop = PendingIntent.getService(this, 0, Intent(this, LocalHotspotService::class.java).setAction(STOP), PendingIntent.FLAG_IMMUTABLE) + startForeground(1842, Notification.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.ic_stat_session) + .setContentTitle(getString(R.string.lan_title)) + .setContentText(getString(R.string.lan_notification)) + .setContentIntent(open).setOngoing(true) + .addAction(Notification.Action.Builder(null, getString(R.string.lan_stop), stop).build()) + .build()) + } + + @Suppress("DEPRECATION") + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == STOP) { + stopSelf() + return START_NOT_STICKY + } + if (requesting || reservation != null) return START_NOT_STICKY + if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + fail(R.string.lan_permission_needed) + return START_NOT_STICKY + } + if (Build.VERSION.SDK_INT >= 28 && !getSystemService(LocationManager::class.java).isLocationEnabled) { + fail(R.string.lan_location_needed) + return START_NOT_STICKY + } + requesting = true + LocalLan.hotspotState.value = HotspotState(starting = true) + try { + val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + if (wifi == null) fail(R.string.lan_failed) + else wifi.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() { + override fun onStarted(ready: WifiManager.LocalOnlyHotspotReservation) { + if (!alive) { + ready.close() + return + } + requesting = false + reservation = ready + val ssid: String? + val password: String? + if (Build.VERSION.SDK_INT >= 30) { + ssid = ready.softApConfiguration.ssid + password = ready.softApConfiguration.passphrase + } else { + ssid = ready.wifiConfiguration?.SSID + password = ready.wifiConfiguration?.preSharedKey + } + if (ssid.isNullOrEmpty() || password.isNullOrEmpty()) fail(R.string.lan_failed) + else LocalLan.hotspotState.value = HotspotState(ssid = ssid, password = password) + } + + override fun onStopped() { + if (!alive) return + LocalLan.hotspotState.value = HotspotState() + stopSelf() + } + + override fun onFailed(reason: Int) { + if (alive) fail(when (reason) { + ERROR_INCOMPATIBLE_MODE -> R.string.lan_incompatible + ERROR_TETHERING_DISALLOWED -> R.string.lan_disallowed + ERROR_NO_CHANNEL -> R.string.lan_no_channel + else -> R.string.lan_failed + }) + } + }, Handler(Looper.getMainLooper())) + } catch (_: SecurityException) { + fail(R.string.lan_permission_needed) + } catch (_: IllegalStateException) { + fail(R.string.lan_incompatible) + } catch (_: UnsupportedOperationException) { + fail(R.string.lan_failed) + } + return START_NOT_STICKY + } + + private fun fail(message: Int) { + LocalLan.hotspotState.value = HotspotState(error = message) + stopSelf() + } + + override fun onDestroy() { + alive = false + reservation?.close() + reservation = null + requesting = false + LocalLan.hotspotState.value = HotspotState(error = LocalLan.hotspot.value.error) + super.onDestroy() + } + + companion object { + private const val CHANNEL = "local-lan" + private const val STOP = "com.droiddeck.launcher.lan.STOP" + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/lan/LocalLan.kt b/app/src/main/java/com/droiddeck/launcher/lan/LocalLan.kt new file mode 100644 index 000000000..407be3643 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/lan/LocalLan.kt @@ -0,0 +1,61 @@ +package com.droiddeck.launcher.lan + +import android.content.Context +import android.net.wifi.WifiManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.SocketException + +data class LanAddress(val address: InetAddress, val prefixLength: Int) + +data class LanInterface( + val name: String, + val index: Int, + val mtu: Int, + val addresses: List, +) + +data class HotspotState(val starting: Boolean = false, val ssid: String? = null, val password: String? = null, val error: Int? = null) + +object LocalLan { + private val selection = MutableStateFlow(null) + val selected = selection.asStateFlow() + internal val hotspotState = MutableStateFlow(HotspotState()) + val hotspot = hotspotState.asStateFlow() + private var multicast: WifiManager.MulticastLock? = null + + @Synchronized fun select(name: String?, context: Context? = null) { + multicast?.let { if (it.isHeld) it.release() } + multicast = null + if (name != null && context != null) { + val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + multicast = wifi?.createMulticastLock("droiddeck-lan")?.apply { + setReferenceCounted(false) + acquire() + } + } + selection.value = name + } + + fun interfaces(): List = try { + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().mapNotNull { network -> + val addresses = network.interfaceAddresses.filter { + it.address is Inet4Address && usableAddress(it.address as Inet4Address, it.networkPrefixLength.toInt()) + }.map { LanAddress(it.address, it.networkPrefixLength.toInt()) } + if (!network.isUp || network.isLoopback || network.isPointToPoint || addresses.isEmpty() || network.index <= 1) null + else LanInterface(network.name, network.index, network.mtu.coerceAtLeast(576), addresses) + }.sortedBy { it.name } + } catch (_: SocketException) { + emptyList() + } catch (_: SecurityException) { + emptyList() + } + + internal fun usableAddress(address: Inet4Address, prefix: Int) = + (address.isSiteLocalAddress || address.isLinkLocalAddress) && prefix in 1..32 + + internal fun selectedInterface() = selected.value?.let { name -> interfaces().firstOrNull { it.name == name } } +} diff --git a/app/src/main/java/com/droiddeck/launcher/lan/LocalLanActivity.kt b/app/src/main/java/com/droiddeck/launcher/lan/LocalLanActivity.kt new file mode 100644 index 000000000..05f563776 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/lan/LocalLanActivity.kt @@ -0,0 +1,133 @@ +package com.droiddeck.launcher.lan + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.AppLanguage +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.ui.ActionRow +import com.droiddeck.launcher.ui.DroidDeckTheme +import com.droiddeck.launcher.ui.Note +import com.droiddeck.launcher.ui.PrimaryButton +import com.droiddeck.launcher.ui.SecondaryButton +import com.droiddeck.launcher.ui.SettingsGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +class LocalLanActivity : ComponentActivity() { + override fun attachBaseContext(base: Context) = super.attachBaseContext(AppLanguage.wrap(base)) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DroidDeckTheme(SessionPrefs.theme(this)) { + Surface(Modifier.fillMaxSize()) { LanSetup() } + } + } + } + + @Composable + private fun LanSetup() { + val hotspot by LocalLan.hotspot.collectAsState() + val selected by LocalLan.selected.collectAsState() + var interfaces by remember { mutableStateOf(emptyList()) } + var denied by remember { mutableStateOf(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + denied = !granted + if (granted) startHotspot() + } + LaunchedEffect(Unit) { + while (true) { + interfaces = withContext(Dispatchers.IO) { LocalLan.interfaces() } + delay(2000) + } + } + Column(Modifier.verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.lan_title), style = MaterialTheme.typography.headlineSmall) + Note(stringResource(R.string.lan_experimental)) + SettingsGroup(stringResource(R.string.lan_wireless)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.lan_wireless_help)) + if (hotspot.ssid != null) { + SelectionContainer { Text(stringResource(R.string.lan_credentials, hotspot.ssid!!, hotspot.password.orEmpty())) } + } + if (hotspot.starting || hotspot.ssid != null) { + SecondaryButton(stringResource(R.string.lan_stop)) { stopService(Intent(this@LocalLanActivity, LocalHotspotService::class.java)) } + } else { + PrimaryButton(stringResource(R.string.lan_host)) { + denied = false + if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) startHotspot() + else permission.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + if (hotspot.starting) Text(stringResource(R.string.lan_starting)) + hotspot.error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } + if (denied) { + Text(stringResource(R.string.lan_permission_needed)) + SecondaryButton(stringResource(R.string.lan_app_settings)) { + openSettings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.parse("package:$packageName")) + } + } + SecondaryButton(stringResource(R.string.lan_wifi_settings)) { openSettings(Settings.ACTION_WIFI_SETTINGS) } + SecondaryButton(stringResource(R.string.lan_location_settings)) { openSettings(Settings.ACTION_LOCATION_SOURCE_SETTINGS) } + } + } + SettingsGroup(stringResource(R.string.lan_wired)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.lan_wired_help)) + SecondaryButton(stringResource(R.string.lan_network_settings)) { openSettings(Settings.ACTION_WIRELESS_SETTINGS) } + } + } + SettingsGroup(stringResource(R.string.lan_interface)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.lan_interface_help)) + if (interfaces.isEmpty()) Text(stringResource(R.string.lan_no_interfaces)) + if (selected != null && interfaces.none { it.name == selected }) Text(stringResource(R.string.lan_interface_lost, selected!!), color = MaterialTheme.colorScheme.error) + } + interfaces.forEach { network -> + ActionRow(network.name, network.addresses.joinToString { it.address.hostAddress.orEmpty() }, + stringResource(if (selected == network.name) R.string.lan_selected else R.string.lan_use), onClick = { LocalLan.select(network.name, this@LocalLanActivity) }) + } + ActionRow(stringResource(R.string.lan_default), null, + stringResource(if (selected == null) R.string.lan_selected else R.string.lan_use), onClick = { LocalLan.select(null) }) + } + SecondaryButton(stringResource(R.string.common_back)) { finish() } + } + } + + private fun startHotspot() = startForegroundService(Intent(this, LocalHotspotService::class.java)) + + private fun openSettings(action: String, data: android.net.Uri? = null) { + val intent = Intent(action, data) + if (intent.resolveActivity(packageManager) != null) startActivity(intent) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt index 45f07471b..becdf2320 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -19,6 +19,9 @@ import android.provider.Settings import android.util.Log import com.droiddeck.launcher.core.SessionPart import com.droiddeck.launcher.core.WifiDiscovery +import com.droiddeck.launcher.lan.LanInterface +import com.droiddeck.launcher.lan.LanAddress +import com.droiddeck.launcher.lan.LocalLan import java.io.File import java.io.IOException import java.net.Inet4Address @@ -29,6 +32,7 @@ import org.json.JSONObject class LinuxNetworkLinkComponent( context: Context, private val rootDir: File, + private val lanInterface: () -> LanInterface? = LocalLan::selectedInterface, ) : SessionPart() { private val appContext = context.applicationContext private val connectivity = @@ -43,9 +47,16 @@ class LinuxNetworkLinkComponent( private var wifiReceiver: BroadcastReceiver? = null private var scanRequestStamp = 0L private var lastScanRequest = -SCAN_INTERVAL_MS + private var publishedLanSelection: String? = null + private var publishedLanInterface: LanInterface? = null @Volatile private var lastNamesAllowed = false private val scanRequests = object : Runnable { override fun run() { + val selection = LocalLan.selected.value + val lan = lanInterface() + synchronized(lock) { + if (selection != publishedLanSelection || lan != publishedLanInterface) write(currentProperties, currentCapabilities) + } if (canReadWifiNames() != lastNamesAllowed) { synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) } requestWifiScan() @@ -153,11 +164,16 @@ class LinuxNetworkLinkComponent( private fun write(properties: LinkProperties?, capabilities: NetworkCapabilities?) { val file = File(rootDir, LINK_FILE) synchronized(lock) { + val selection = LocalLan.selected.value + val lan = lanInterface() try { file.parentFile?.mkdirs() val staged = File(file.path + ".staged") - staged.writeText(describe(properties)) + staged.writeText(if (selection == null) describe(properties) + else if (lan != null) describe(null, lan) else "") if (!staged.renameTo(file)) throw IOException("Could not replace $file") + publishedLanSelection = selection + publishedLanInterface = lan } catch (e: IOException) { Log.w(TAG, "Could not publish the network link", e) } @@ -167,13 +183,18 @@ class LinuxNetworkLinkComponent( } @Suppress("DEPRECATION") - private fun writeNetworkState(properties: LinkProperties?, capabilities: NetworkCapabilities?) { + private fun writeNetworkState(defaultProperties: LinkProperties?, defaultCapabilities: NetworkCapabilities?) { + val selection = LocalLan.selected.value + val lan = if (selection != null) lanInterface() else null + val properties = if (selection == null) defaultProperties else null + val capabilities = if (selection == null) defaultCapabilities else null 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 { + lan != null -> "other" capabilities == null -> "none" capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" @@ -183,9 +204,10 @@ class LinuxNetworkLinkComponent( } 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("interface", lan?.name ?: properties?.interfaceName ?: "android") + put("mtu", lan?.mtu ?: properties?.mtu?.takeIf { it > 0 } ?: DEFAULT_MTU) + put("connected", lan != null || (properties != null && capabilities != null)) + put("localLan", selection != 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) }) @@ -205,7 +227,7 @@ class LinuxNetworkLinkComponent( } }) put("addresses", JSONArray().apply { - properties?.linkAddresses.orEmpty().forEach { + (lan?.addresses ?: properties?.linkAddresses.orEmpty().map { LanAddress(it.address, it.prefixLength) }).forEach { put(JSONObject().put("address", it.address.hostAddress?.substringBefore('%')).put("prefix", it.prefixLength)) } }) @@ -277,13 +299,14 @@ class LinuxNetworkLinkComponent( } } - private fun describe(properties: LinkProperties?): String { - val addresses = properties?.linkAddresses.orEmpty() + private fun describe(properties: LinkProperties?, lan: LanInterface? = null): String { + val addresses = (lan?.addresses ?: properties?.linkAddresses.orEmpty().map { LanAddress(it.address, it.prefixLength) }) .filter { it.address is Inet4Address || it.address is Inet6Address } - val name = properties?.interfaceName?.takeIf { addresses.isNotEmpty() } ?: OFFLINE_NAME - val mtu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) properties?.mtu ?: 0 else 0 + val name = (lan?.name ?: properties?.interfaceName)?.takeIf { addresses.isNotEmpty() } ?: OFFLINE_NAME + val mtu = lan?.mtu ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) properties?.mtu ?: 0 else 0 return buildString { - append("if $name $LINK_INDEX ${if (mtu > 0) mtu else DEFAULT_MTU}\n") + append("if $name ${lan?.index ?: LINK_INDEX} ${if (mtu > 0) mtu else DEFAULT_MTU}\n") + if (lan != null) append("selected 1\n") append("mac ${seededMac()}\n") if (addresses.isEmpty()) { append("addr $OFFLINE_ADDRESS\n") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c2582837f..0c2712412 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -335,6 +335,11 @@ internal fun SetupPanel( } } SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) } + SettingsGroup(stringResource(R.string.lan_title)) { + ActionRow(stringResource(R.string.lan_title), stringResource(R.string.lan_setup_hint), stringResource(R.string.setup_manage), onClick = { + ctx.startActivity(android.content.Intent(ctx, com.droiddeck.launcher.lan.LocalLanActivity::class.java)) + }) + } } 1 -> { val controller = s.controller diff --git a/app/src/main/res/values/lan.xml b/app/src/main/res/values/lan.xml new file mode 100644 index 000000000..f8ead5c5c --- /dev/null +++ b/app/src/main/res/values/lan.xml @@ -0,0 +1,32 @@ + + + Local LAN · Experimental + Offline Wi-Fi hotspot and USB/Ethernet LAN setup + Device testing is still needed. Use a game with LAN or direct-IP multiplayer. LAN setup does not add multiplayer to a game or replace Steam authentication. Set this up before launching the game. + Wireless LAN + Host creates a password-protected local network without a router or internet. It may disconnect your current Wi-Fi. On the other device, join using Android Wi-Fi settings and choose to stay connected without internet. Then select the LAN interface below on both devices. If cellular takes priority, turn mobile data off while playing. + Host offline hotspot + Stop hotspot + Starting hotspot… + Offline hotspot requested. Tap to manage LAN. + Network: %1$s\nPassword: %2$s + Android could not start a usable hotspot on this device. + Turn off any existing hotspot or Wi-Fi Direct connection, then try again. + Hotspots are disabled by this device’s policy. + No Wi-Fi channel is available. Check Wi-Fi and airplane mode, then retry. + Hosting needs precise location permission for this app’s Android compatibility level. Enable it in App settings, then retry. + Turn on Android Location services, then retry hosting. + App settings + Join in Wi-Fi settings + Location settings + USB-C / Ethernet LAN + Connect a data-capable USB-C cable and enable USB tethering on one device in Android settings. This only works if the other device supports the USB network driver and host role. If no LAN interface appears, try reversing the cable’s host/device roles or use USB Ethernet adapters. Charging or file-transfer mode alone does not create a LAN. DroidDeck cannot enable USB tethering itself. + Android network settings + Game LAN interface + Select the interface whose IP belongs to your local connection. DroidDeck reports this interface and its real index to the Linux session. This does not change Android routing; use the host’s IP in the game if LAN discovery fails. Selection lasts until the app process exits or you return to the default below. + No local IPv4 interfaces are visible yet. Connect the devices first. + %1$s is unavailable. Reconnect or choose another interface. + Use for games + Selected + Android default network + diff --git a/app/src/test/java/com/droiddeck/launcher/lan/LocalHotspotServiceTest.kt b/app/src/test/java/com/droiddeck/launcher/lan/LocalHotspotServiceTest.kt new file mode 100644 index 000000000..a7df4a17e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/lan/LocalHotspotServiceTest.kt @@ -0,0 +1,138 @@ +package com.droiddeck.launcher.lan + +import android.Manifest +import android.app.Application +import android.content.Intent +import android.location.LocationManager +import android.net.wifi.SoftApConfiguration +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.os.Handler +import com.droiddeck.launcher.R +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowWifiManager +import org.robolectric.shadow.api.Shadow + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28, 34], application = Application::class, shadows = [LanWifiShadow::class, LanReservationShadow::class, LanSoftApShadow::class]) +class LocalHotspotServiceTest { + private lateinit var controller: org.robolectric.android.controller.ServiceController + private val service get() = controller.get() + + @Before fun prepare() { + LocalLan.hotspotState.value = HotspotState() + LanWifiShadow.callback = null + LanWifiShadow.requests = 0 + LanWifiShadow.unsupported = false + LanReservationShadow.closes = 0 + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + shadowOf(app.getSystemService(LocationManager::class.java)).setLocationEnabled(true) + controller = Robolectric.buildService(LocalHotspotService::class.java).create() + } + + @After fun cleanUp() { + controller.destroy() + LocalLan.hotspotState.value = HotspotState() + } + + private fun start() = service.onStartCommand(Intent(), 0, 1) + + @Test fun repeatedStartsShareOneRequestAndDestroyReleasesTheReservation() { + start() + start() + assertEquals(1, LanWifiShadow.requests) + assertTrue(LocalLan.hotspot.value.starting) + LanWifiShadow.callback!!.onStarted(Shadow.newInstanceOf(WifiManager.LocalOnlyHotspotReservation::class.java)) + assertEquals("DroidDeck-test", LocalLan.hotspot.value.ssid) + assertEquals("test-password", LocalLan.hotspot.value.password) + start() + assertEquals(1, LanWifiShadow.requests) + service.onDestroy() + assertEquals(1, LanReservationShadow.closes) + assertNull(LocalLan.hotspot.value.password) + } + + @Test fun aLateReservationAfterShutdownIsClosedWithoutPublishingCredentials() { + start() + service.onDestroy() + LanWifiShadow.callback!!.onStarted(Shadow.newInstanceOf(WifiManager.LocalOnlyHotspotReservation::class.java)) + assertEquals(1, LanReservationShadow.closes) + assertNull(LocalLan.hotspot.value.ssid) + assertFalse(LocalLan.hotspot.value.starting) + } + + @Test fun failureExplainsIncompatibleTethering() { + start() + LanWifiShadow.callback!!.onFailed(WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE) + assertEquals(R.string.lan_incompatible, LocalLan.hotspot.value.error) + assertTrue(shadowOf(service).isStoppedBySelf) + } + + @Test fun missingPermissionDoesNotRequestWifi() { + shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + start() + assertEquals(0, LanWifiShadow.requests) + assertEquals(R.string.lan_permission_needed, LocalLan.hotspot.value.error) + } + + @Test fun disabledLocationDoesNotRequestWifi() { + shadowOf(service.getSystemService(LocationManager::class.java)).setLocationEnabled(false) + start() + assertEquals(0, LanWifiShadow.requests) + assertEquals(R.string.lan_location_needed, LocalLan.hotspot.value.error) + } + + @Test fun unsupportedHardwareStopsCleanly() { + LanWifiShadow.unsupported = true + start() + assertEquals(R.string.lan_failed, LocalLan.hotspot.value.error) + assertTrue(shadowOf(service).isStoppedBySelf) + } +} + +@Implements(WifiManager::class) +class LanWifiShadow : ShadowWifiManager() { + @Implementation + fun startLocalOnlyHotspot(callback: WifiManager.LocalOnlyHotspotCallback, handler: Handler?) { + if (unsupported) throw UnsupportedOperationException() + requests++ + Companion.callback = callback + } + + companion object { + var callback: WifiManager.LocalOnlyHotspotCallback? = null + var requests = 0 + var unsupported = false + } +} + +@Implements(WifiManager.LocalOnlyHotspotReservation::class) +class LanReservationShadow { + @Implementation fun close() { closes++ } + + @Implementation(maxSdk = 29) + fun getWifiConfiguration() = WifiConfiguration().apply { SSID = "DroidDeck-test"; preSharedKey = "test-password" } + + @Implementation(minSdk = 30) + fun getSoftApConfiguration(): SoftApConfiguration = Shadow.newInstanceOf(SoftApConfiguration::class.java) + + companion object { var closes = 0 } +} + +@Implements(SoftApConfiguration::class, minSdk = 30) +class LanSoftApShadow { + @Implementation fun getSsid() = "DroidDeck-test" + @Implementation fun getPassphrase() = "test-password" +} diff --git a/app/src/test/java/com/droiddeck/launcher/lan/LocalLanTest.kt b/app/src/test/java/com/droiddeck/launcher/lan/LocalLanTest.kt new file mode 100644 index 000000000..9d2292709 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/lan/LocalLanTest.kt @@ -0,0 +1,129 @@ +package com.droiddeck.launcher.lan + +import android.app.Application +import android.net.LinkProperties +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Looper +import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent +import java.io.File +import java.net.Inet4Address +import java.net.InetAddress +import java.nio.file.Files +import java.time.Duration +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 28, 34], application = Application::class) +@LooperMode(LooperMode.Mode.PAUSED) +class LocalLanTest { + private lateinit var root: File + private var lan: LanInterface? = null + private lateinit var component: LinuxNetworkLinkComponent + private val host = LanInterface("ap0", 23, 1400, listOf(LanAddress(InetAddress.getByName("192.168.73.1"), 24))) + + @Before fun prepare() { + root = Files.createTempDirectory("lan-test").toFile() + lan = host + LocalLan.select(null) + component = LinuxNetworkLinkComponent(RuntimeEnvironment.getApplication(), root) { lan } + } + + @After fun cleanUp() { + component.stop() + LocalLan.select(null) + root.deleteRecursively() + } + + private fun state() = JSONObject(File(root, "etc/droiddeck-network.json").readText()) + private fun link() = File(root, "etc/droiddeck-net").readText() + + @Test fun selectedHotspotPublishesRealAddressAndKernelIndexWithoutPretendingInternetIsAvailable() { + LocalLan.select(host.name) + component.publish() + assertTrue(link().startsWith("if ap0 23 1400\n")) + assertTrue(link().contains("selected 1\n")) + assertTrue(link().contains("addr 192.168.73.1 24\n")) + assertFalse(link().contains("10.0.0.2")) + assertFalse(link().contains("gw ")) + assertTrue(state().getBoolean("connected")) + assertTrue(state().getBoolean("localLan")) + assertFalse(state().getBoolean("validated")) + assertEquals("ap0", state().getString("interface")) + assertEquals(0, state().getJSONArray("gateways").length()) + } + + @Test fun disconnectClearsSelectedInterfaceAndNeverInventsAnAddress() { + LocalLan.select(host.name) + component.start() + shadowOf(Looper.getMainLooper()).idle() + lan = null + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1)) + assertEquals("", link()) + assertFalse(state().getBoolean("connected")) + assertEquals(0, state().getJSONArray("addresses").length()) + assertEquals(host.name, LocalLan.selected.value) + lan = host.copy(addresses = listOf(LanAddress(InetAddress.getByName("192.168.74.1"), 24))) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1)) + assertTrue(link().contains("addr 192.168.74.1 24\n")) + } + + @Test fun selectingALanHoldsMulticastUntilResetAndReleasesThePreviousLock() { + val context = RuntimeEnvironment.getApplication() + val field = LocalLan.javaClass.getDeclaredField("multicast").apply { isAccessible = true } + LocalLan.select(host.name, context) + val first = field.get(LocalLan) as WifiManager.MulticastLock + assertTrue(first.isHeld) + LocalLan.select("usb0", context) + assertFalse(first.isHeld) + val second = field.get(LocalLan) as WifiManager.MulticastLock + assertTrue(second.isHeld) + LocalLan.select(null) + assertFalse(second.isHeld) + } + + @Test fun returningToDefaultRestoresExistingOfflineBehavior() { + LocalLan.select(host.name) + component.publish() + LocalLan.select(null) + component.publish() + assertTrue(link().startsWith("if eth0 2 1500\n")) + assertTrue(link().contains("addr 10.0.0.2 24\n")) + assertFalse(link().contains("selected 1")) + assertFalse(state().getBoolean("localLan")) + } + + @Test fun selectedLanDoesNotInheritDefaultNetworkAddressesOrValidation() { + LocalLan.select(host.name) + val properties = LinkProperties() + val caps = NetworkCapabilities() + component.javaClass.getDeclaredMethod("write", LinkProperties::class.java, NetworkCapabilities::class.java).apply { + isAccessible = true + }.invoke(component, properties, caps) + assertEquals("192.168.73.1", state().getJSONArray("addresses").getJSONObject(0).getString("address")) + assertFalse(state().getBoolean("validated")) + assertEquals(0, state().getJSONArray("dns").length()) + } + + @Test fun lanCandidatesExcludePublicLoopbackAndInvalidPrefixes() { + fun usable(ip: String, prefix: Int = 24) = LocalLan.usableAddress(InetAddress.getByName(ip) as Inet4Address, prefix) + assertTrue(usable("192.168.43.1")) + assertTrue(usable("10.15.0.1")) + assertTrue(usable("172.20.0.1")) + assertTrue(usable("169.254.2.3", 16)) + assertFalse(usable("127.0.0.1")) + assertFalse(usable("8.8.8.8")) + assertFalse(usable("192.168.43.1", 0)) + assertFalse(usable("192.168.43.1", 33)) + } +} diff --git a/docs/local-lan.md b/docs/local-lan.md new file mode 100644 index 000000000..1bfd11edd --- /dev/null +++ b/docs/local-lan.md @@ -0,0 +1,108 @@ +# Local LAN experiment + +Status: implemented prototype; no two-device hardware or game validation. Open +**Settings → Overview → Local LAN · Experimental** before starting a session. + +## Scope + +Two devices each run their own copy of a LAN-capable game. One device can create +an Android local-only hotspot; the other joins it through Android Wi-Fi settings. +No router, SIM, mobile data, internet connection, or root access is required by +the hotspot API. Android chooses the network name, password, band, and subnet. +Hardware/OEM policy can reject the request. Existing Wi-Fi can disconnect. + +The separate USB path uses an IP link that Android has already configured. A +data-capable USB-C cable plus USB tethering on one device can work **only if** +the receiving device supports USB host mode and that tethering network driver +(RNDIS/NCM, depending on device). A USB-C connector alone guarantees none of this. +USB Ethernet adapters and an Ethernet cable are another possible transport. +DroidDeck does not create USB gadget functions, force USB roles, enable tethering, +or install kernel drivers. + +This does not implement remote play, controller forwarding, online matchmaking, +an internet-sharing hotspot, a virtual LAN tunnel, or generic split-screen co-op. +Steam authentication, account/licensing requirements, DRM, and a game's own +network implementation still apply. Prepare and test offline mode before travel. + +## Linux integration + +The guest uses Android's kernel networking rather than a separate network +namespace. Selecting **Use for games** advertises an actual private/link-local +IPv4 interface, subnet, MTU, and kernel interface index through `droiddeck-net`. +The existing preload uses that snapshot for `getifaddrs()` and `if_nameindex()`; +an explicit selection overrides normal enumeration. The real index matters for +multicast membership. Broadcast addresses are computed from the real prefix. +NetworkManager's compatibility snapshot also reports the selected LAN without +claiming internet validation, an upstream gateway, or DNS. Android's default +network continues to supply the guest's resolver configuration. + +Selection is process-local, survives launcher/activity recreation, and is cleared +by **Android default network** or process exit. A Wi-Fi multicast lock is held +while selected and released on reset/process death. The session rechecks the +selected interface every second, including address changes and unplug/reconnect. +An absent selected interface clears its advertised addresses rather than +substituting cellular or the usual synthetic offline address. The hotspot is +owned by a foreground service, so launching a game or leaving the setup screen +does not release the reservation. Stop it from the screen or its notification. +The service releases late reservations delivered after shutdown, and does not +restart a hotspot automatically after process death. + +**Advertising a link does not install routes or bind guest sockets to an Android +Network.** `ConnectivityManager.bindProcessToNetwork()` alone is insufficient to +establish that glibc/FEX subprocess sockets obey Android's bionic networking +hooks. This prototype leaves kernel routing to Android. On the joining device, +accept “stay connected without internet” and turn off mobile data if Android +prefers cellular. First try direct IP; a visible interface does not prove that +unicast, broadcast, or multicast traffic works. VPNs, OEM firewall/client +isolation, Steam/Wine behavior, and games that insist on internet remain risks. + +## Prior art and alternatives + +Reviewed on 2026-10-24; no third-party source was copied. + +| Approach | Source and conclusion | +| --- | --- | +| Android local-only hotspot | [Android guide](https://developer.android.com/develop/connectivity/wifi/localonlyhotspot) and [reservation lifecycle](https://developer.android.com/reference/android/net/wifi/WifiManager.LocalOnlyHotspotReservation): public API since 26, designed for nearby devices without internet. Fits ordinary Wi-Fi clients. DroidDeck currently targets API 28, so hosting requests precise location and requires Location services; a future target-33 migration must revisit `NEARBY_WIFI_DEVICES`. | +| Wi-Fi Direct | [Android guide](https://developer.android.com/develop/connectivity/wifi/wifip2p): possible without an access point, but needs discovery/group ownership/connection recovery and device capability handling. A second wireless backend is deferred until hotspot traffic is verified. | +| Steam Deck wired LAN | [dafta's USB Ethernet gadget script](https://gist.github.com/dafta/0aadeba3aa8bcbbc8b92a233977571ed), [DeckMTP](https://github.com/dafta/DeckMTP), and [GadgetDeck](https://github.com/Frederic98/GadgetDeck): useful USB gadget prior art. The script needs root/configfs, and DeckMTP documents enabling dual-role USB in firmware. A normal Android app cannot transplant that setup. GadgetDeck lists Ethernet multiplayer as a goal rather than evidence of tested DroidDeck support. | +| Steam Deck wireless LAN | [NetDeck](https://github.com/fewtarius/NetDeck) creates hotspots through Linux networking tools with root access and recommends a second Wi-Fi adapter. Its privileges and adapter assumptions differ from an Android app. | +| Android co-op streaming | [EasyControl fork](https://github.com/daitj/Easycontrol), identified from [the Thor co-op guide](https://www.reddit.com/r/AynThor/comments/1usuxsx/share_your_screen_to_another_device_for_couch/): Android-to-Android scrcpy remote control/streaming, including USB. The guide uses a Shizuku fork and runs the game on one device. A stream or ADB port forward does not create a LAN for two independent games. | + +A generic cable-only fallback using Android USB host/accessory bulk endpoints +would require a new packet tunnel and an IP stack/VPN integration, with permission, +VPN coexistence, routing, MTU, UDP/multicast, and lifecycle work on both ends. +That is substantially larger than socket forwarding and is deferred pending +evidence that stock USB tethering is inadequate on the target handhelds. + +## Validation before promoting the experiment + +Automated coverage checks hotspot request deduplication, permission/location +failures, unsupported hardware, reservation cleanup (including late callbacks), +LAN snapshot/index/address publication, disconnect/reconnect, returning to default, +and native interface enumeration/broadcast calculation. These tests simulate +platform behavior; they do not verify a radio, cable, Android routing, or a game. + +Use two physical devices, recording models, OS builds, transport, selected +interfaces/addresses, game versions, and whether mobile data/VPNs were enabled: + +1. With no external Wi-Fi and mobile data off, host/join a hotspot. Check its + credentials, leaving/reopening setup, launching Steam/Desktop, notification + stop, permission denial, Location off, and process death. +2. In both Linux sessions compare `cat /etc/droiddeck-net`, + `cat /etc/droiddeck-network.json`, and Python + `socket.if_nameindex()`; confirm distinct real addresses in the same subnet. +3. Test bidirectional TCP and UDP between guest sessions, binding the real + addresses and using a high port (for example 27842). Repeat after disconnect, + changed subnet, Android backgrounding, and resume. Android-to-Android reachability + alone does not establish guest-to-guest reachability. +4. Test a subnet-directed UDP broadcast and UDP multicast with membership on the + selected interface; then test an actual game's LAN browser and direct-IP join. + Record failures separately from successful direct-IP traffic. +5. Repeat with mobile data active and with a VPN to establish routing limitations. +6. For USB, try both host/device roles and tethering directions; confirm an + actual interface/address before testing guest traffic. Repeat with airplane + mode/no SIM, cable unplug/replug, and USB Ethernet adapters. Check charging + direction, thermal load, and sustained play. + +Keep this experimental until those results exist; successful builds are not +evidence of working multiplayer. diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 1782d0f0d..f258875e4 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -23,7 +23,9 @@ #include #include +#ifndef LINK_FILE #define LINK_FILE "/etc/droiddeck-net" +#endif #define LO_NAME "lo" #define LO_INDEX 1 #define LO_MTU 65536 @@ -45,6 +47,7 @@ struct link { int addr_count; struct link_addr gateways[2]; int gateway_count; + unsigned selected; }; static int parse_addr(const char *text, struct link_addr *out) { @@ -67,6 +70,7 @@ static int read_link(struct link *link) { char line[160], text[64]; unsigned m[6], prefix; while (fgets(line, sizeof(line), f)) { + if (sscanf(line, "selected %u", &link->selected) == 1) continue; if (sscanf(line, "if %15s %u %d", link->name, &link->index, &link->mtu) == 3) continue; if (sscanf(line, "mac %2x:%2x:%2x:%2x:%2x:%2x", m, m + 1, m + 2, m + 3, m + 4, m + 5) == 6) { for (int i = 0; i < 6; i++) link->mac[i] = (unsigned char) m[i]; @@ -208,7 +212,9 @@ int getifaddrs(struct ifaddrs **out) { if (real == NULL) real = (getifaddrs_fn) dlsym(RTLD_NEXT, "getifaddrs"); if (real_freeifaddrs == NULL) real_freeifaddrs = (freeifaddrs_fn) dlsym(RTLD_NEXT, "freeifaddrs"); struct ifaddrs *list = NULL; - int asked = !fresh(&ifaddrs_unusable_at); + struct link link; + int have_link = load_link(&link); + int asked = !(have_link && link.selected) && !fresh(&ifaddrs_unusable_at); int rc = -1, saved = errno; if (asked) { rc = real(&list); @@ -219,8 +225,7 @@ int getifaddrs(struct ifaddrs **out) { } } - struct link link; - if (!load_link(&link)) { + if (!have_link) { if (!asked) return real(out); if (rc == 0) *out = list; errno = saved; @@ -269,7 +274,9 @@ struct if_nameindex *if_nameindex(void) { static if_nameindex_fn real; if (real == NULL) real = (if_nameindex_fn) dlsym(RTLD_NEXT, "if_nameindex"); struct if_nameindex *list = NULL; - int asked = !fresh(&nameindex_unusable_at); + struct link link; + int have_link = load_link(&link); + int asked = !(have_link && link.selected) && !fresh(&nameindex_unusable_at); int saved = errno; if (asked) { list = real(); @@ -277,9 +284,8 @@ struct if_nameindex *if_nameindex(void) { if (list && list[0].if_index != 0) return list; } - struct link link; struct name_block *block; - if (!load_link(&link)) { + if (!have_link) { if (!asked) return real(); errno = saved; return list; diff --git a/tools/tests/test_lan_netif_preload.py b/tools/tests/test_lan_netif_preload.py new file mode 100644 index 000000000..26ac0ffb8 --- /dev/null +++ b/tools/tests/test_lan_netif_preload.py @@ -0,0 +1,93 @@ +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + +STUBS = r""" +int bl_ntsync_ioctl(int fd, unsigned long req, void *arg, int *rc) { return 0; } +int bl_drm_gem_close(int fd, unsigned long req, void *arg, int *rc) { return 0; } +int bl_venc_ioctl(int fd, unsigned long req, void *arg, int *rc) { return 0; } +int bl_kgsl_poll(void *real, int fd, unsigned long req, void *arg, int *rc) { return 0; } +int bl_winebus_rumble_ioctl(void *real, int fd, unsigned long req, void *arg, void *caller, int *rc) { return 0; } +int bl_writewatch_ioctl(void *real, int fd, unsigned long req, void *arg, int *rc) { return 0; } +void bl_kgsl_chip_id_fixup(unsigned long req, void *arg, int rc) {} +int bl_status_without_tracer(const char *path, int flags, const void *caller) { return -1; } +int quest_kgsl_ioctl(int (*real)(int, unsigned long, void *), int fd, unsigned long req, void *arg) { return real(fd, req, arg); } +""" + +PROGRAM = r""" +#include +#include +#include +#include +int main(void) { + struct ifaddrs *list; + if (getifaddrs(&list) != 0) return 2; + for (struct ifaddrs *it = list; it; it = it->ifa_next) { + if (!it->ifa_addr || it->ifa_addr->sa_family != AF_INET) continue; + char ip[INET_ADDRSTRLEN], broadcast[INET_ADDRSTRLEN] = "-"; + inet_ntop(AF_INET, &((struct sockaddr_in *)it->ifa_addr)->sin_addr, ip, sizeof(ip)); + if ((it->ifa_flags & IFF_BROADCAST) && it->ifa_broadaddr) + inet_ntop(AF_INET, &((struct sockaddr_in *)it->ifa_broadaddr)->sin_addr, broadcast, sizeof(broadcast)); + printf("addr %s %s %s\n", it->ifa_name, ip, broadcast); + } + freeifaddrs(list); + struct if_nameindex *names = if_nameindex(); + if (!names) return 3; + for (struct if_nameindex *it = names; it->if_index; it++) printf("index %s %u\n", it->if_name, it->if_index); + if_freenameindex(names); + printf("selected-index %u\n", if_nametoindex("lan-test")); + return 0; +} +""" + + +@unittest.skipUnless(sys.platform == "linux" and shutil.which("cc"), "needs Linux and a C compiler") +class LanNetifPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory() + cls.directory = Path(cls.temp.name) + cls.link = cls.directory / "network" + stub = cls.directory / "stubs.c" + stub.write_text(STUBS) + program = cls.directory / "program.c" + program.write_text(PROGRAM) + cls.preload = cls.directory / "libnetif.so" + cls.program = cls.directory / "program" + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", f'-DLINK_FILE="{cls.link}"', + "-o", str(cls.preload), str(ROOT / "tools/linuxfs/preload/netif.c"), str(stub), "-ldl"], check=True) + subprocess.run(["cc", "-Wall", "-Werror", "-o", str(cls.program), str(program)], check=True) + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + def run_program(self, selected): + self.link.write_text("if lan-test 23 1400\nmac 02:12:34:56:78:90\naddr 192.168.73.1 24\n" + selected) + return subprocess.check_output([str(self.program)], env=dict(os.environ, LD_PRELOAD=str(self.preload)), text=True, timeout=10) + + def test_selected_interface_overrides_readable_system_interfaces_and_preserves_broadcast(self): + output = self.run_program("selected 1\n") + self.assertIn("addr lan-test 192.168.73.1 192.168.73.255\n", output) + self.assertIn("index lan-test 23\n", output) + self.assertIn("selected-index 23\n", output) + self.assertEqual(2, sum(line.startswith("addr ") for line in output.splitlines())) + self.assertEqual(2, sum(line.startswith("index ") for line in output.splitlines())) + + def test_default_mode_keeps_real_interfaces_when_readable(self): + baseline = subprocess.check_output([str(self.program)], text=True, timeout=10) + if not any(line.startswith("addr ") and not line.startswith("addr lo ") for line in baseline.splitlines()): + self.skipTest("needs a non-loopback IPv4 interface") + output = self.run_program("") + self.assertNotIn("addr lan-test", output) + self.assertNotIn("index lan-test", output) + + +if __name__ == "__main__": + unittest.main()