feat: prototype offline local LAN setup

This commit is contained in:
Kurt Himebauch committed 2026-10-24 19:34:15 -04:00
1 parent 74a6a88df8
commit de104f64a2
12 files changed
+874 -17

No files matched your search

+2
View File
@@ -45,6 +45,8 @@
android:isGame="true">
<meta-data android:name="android.game_mode_config" android:resource="@xml/game_mode_config" />
<activity android:name=".lan.LocalLanActivity" android:exported="false" />
<service android:name=".lan.LocalHotspotService" android:exported="false" />
<service android:name=".glasses.LumaRgbCaptureService" android:exported="false"
android:process=":rgb_diagnostic" />
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s" />
@@ -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"
}
}
@@ -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<LanAddress>,
)
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<String?>(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<LanInterface> = 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 } }
}
@@ -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<LanInterface>()) }
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)
}
}
@@ -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")
@@ -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
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="lan_title">Local LAN · Experimental</string>
<string name="lan_setup_hint">Offline Wi-Fi hotspot and USB/Ethernet LAN setup</string>
<string name="lan_experimental">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.</string>
<string name="lan_wireless">Wireless LAN</string>
<string name="lan_wireless_help">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.</string>
<string name="lan_host">Host offline hotspot</string>
<string name="lan_stop">Stop hotspot</string>
<string name="lan_starting">Starting hotspot…</string>
<string name="lan_notification">Offline hotspot requested. Tap to manage LAN.</string>
<string name="lan_credentials">Network: %1$s\nPassword: %2$s</string>
<string name="lan_failed">Android could not start a usable hotspot on this device.</string>
<string name="lan_incompatible">Turn off any existing hotspot or Wi-Fi Direct connection, then try again.</string>
<string name="lan_disallowed">Hotspots are disabled by this device’s policy.</string>
<string name="lan_no_channel">No Wi-Fi channel is available. Check Wi-Fi and airplane mode, then retry.</string>
<string name="lan_permission_needed">Hosting needs precise location permission for this app’s Android compatibility level. Enable it in App settings, then retry.</string>
<string name="lan_location_needed">Turn on Android Location services, then retry hosting.</string>
<string name="lan_app_settings">App settings</string>
<string name="lan_wifi_settings">Join in Wi-Fi settings</string>
<string name="lan_location_settings">Location settings</string>
<string name="lan_wired">USB-C / Ethernet LAN</string>
<string name="lan_wired_help">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.</string>
<string name="lan_network_settings">Android network settings</string>
<string name="lan_interface">Game LAN interface</string>
<string name="lan_interface_help">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.</string>
<string name="lan_no_interfaces">No local IPv4 interfaces are visible yet. Connect the devices first.</string>
<string name="lan_interface_lost">%1$s is unavailable. Reconnect or choose another interface.</string>
<string name="lan_use">Use for games</string>
<string name="lan_selected">Selected</string>
<string name="lan_default">Android default network</string>
</resources>
@@ -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<LocalHotspotService>
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"
}
@@ -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))
}
}
+108
View File
@@ -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.
+12 -6
View File
@@ -23,7 +23,9 @@
#include <time.h>
#include <unistd.h>
#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;
+93
View File
@@ -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 <arpa/inet.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <stdio.h>
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()