Merge branch 'claude/card-game-physics-animations-5b6bfi' into dev

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	app/src/main/java/org/prolibertate/games/net/LanTransport.kt
This commit is contained in:
James Wallace
2026-10-09 15:47:47 +00:00
6 changed files with 511 additions and 42 deletions
@@ -8,6 +8,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -23,7 +26,9 @@ import java.util.Collections
*
* NSD is used rather than a UDP broadcast ping because it works across the
* Wi-Fi isolation settings most home routers ship with, and because Android
* gives us the resolver for free.
* gives us the resolver for free. It is not, however, trusted to be enough:
* see [endpoint] and [LocalNetwork] for what a phone's own hotspot does to
* both discovery and routing, and for the way round it.
*/
class LanTransport(private val context: Context) : Transport {
@@ -34,12 +39,18 @@ class LanTransport(private val context: Context) : Transport {
private val wifiManager: WifiManager
get() = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
private val multicast = MulticastGuard(context)
private var serverSocket: ServerSocket? = null
private var registrationListener: NsdManager.RegistrationListener? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var wifiLock: WifiManager.WifiLock? = null
private val _endpoint = MutableStateFlow<HostEndpoint?>(null)
/** Where this device is listening while hosting, so it can be read out. */
val endpoint: StateFlow<HostEndpoint?> = _endpoint.asStateFlow()
override fun isAvailable(): Boolean = true
/**
@@ -75,9 +86,15 @@ class LanTransport(private val context: Context) : Transport {
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
holdRadio()
// Port 0 lets the OS pick a free port, which is then advertised.
val server = ServerSocket(0)
// The known port when it can be had, so that joining by hand needs an
// address and nothing else; anything free if something already has it.
val server = runCatching { ServerSocket(DEFAULT_HOST_PORT) }
.getOrElse { ServerSocket(0) }
serverSocket = server
_endpoint.value = HostEndpoint(localIpv4Addresses(), server.localPort)
// Advertising is mDNS, and mDNS is multicast.
multicast.acquire()
val serviceInfo = NsdServiceInfo().apply {
serviceName = "$SERVICE_NAME @ $displayName"
@@ -116,6 +133,7 @@ class LanTransport(private val context: Context) : Transport {
acceptJob.cancel()
stopAdvertising()
runCatching { server.close() }
_endpoint.value = null
}
}
@@ -199,6 +217,10 @@ class LanTransport(private val context: Context) : Transport {
}
discoveryListener = listener
// Listening for mDNS replies needs the multicast filter held open just
// as much as answering does.
multicast.acquire()
publish()
runCatching {
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
@@ -215,6 +237,11 @@ class LanTransport(private val context: Context) : Transport {
withContext(Dispatchers.IO) {
holdRadio()
val socket = Socket()
// Before connecting, and this is the fix for hotspot play: a
// hotspot has no internet behind it, so Android leaves mobile data
// as the default network and an unbound socket goes out over
// cellular looking for a 192.168 address.
bindForTarget(context, socket, host.address)
socket.connect(InetSocketAddress(host.address, host.port), CONNECT_TIMEOUT_MS)
socket.tcpNoDelay = true
socket.keepAlive = true
@@ -243,9 +270,11 @@ class LanTransport(private val context: Context) : Transport {
override fun stop() {
stopAdvertising()
stopDiscovery()
multicast.release()
runCatching { serverSocket?.close() }
serverSocket = null
releaseRadio()
_endpoint.value = null
}
private companion object {
@@ -36,6 +36,8 @@ class LobbyController(
val hostName: String = "",
val seats: List<PlayerSlot> = emptyList(),
val discovered: List<DiscoveredHost> = emptyList(),
/** Where this device is listening while hosting, for reading out. */
val endpoint: HostEndpoint? = null,
val message: String? = null,
val started: TableConfig? = null,
)
@@ -112,6 +114,15 @@ class LobbyController(
}
}
}
// Discovery is not guaranteed — a phone sharing its own hotspot cannot
// be relied on to advertise over the tethering interface — so where the
// host is listening is put on its screen to be read out.
jobs += scope.launch {
lan.endpoint.collect { endpoint ->
_state.value = _state.value.copy(endpoint = endpoint)
}
}
}
private fun onClientConnected(connection: Connection) {
@@ -254,6 +265,36 @@ class LobbyController(
}
}
/**
* Joins a host at an address somebody typed in.
*
* The way in when discovery cannot work. Two phones on one phone's hotspot
* is exactly that case: mDNS over a tethering interface is unreliable in
* both directions, and no amount of listening harder fixes it, but the
* address is right there on the host's screen and a TCP connection to it
* works perfectly well once the socket is pointed at the right network.
*/
fun joinAt(typed: String, displayName: String, peerId: String) {
val parsed = parseManualAddress(typed)
if (parsed == null) {
_state.value = _state.value.copy(
message = "That doesn't look like an address. Try 192.168.43.1",
)
return
}
join(
host = DiscoveredHost(
id = "lan:${parsed.address}:${parsed.port}",
name = parsed.address,
kind = TransportKind.LAN,
address = parsed.address,
port = parsed.port,
),
displayName = displayName,
peerId = peerId,
)
}
fun join(host: DiscoveredHost, displayName: String, peerId: String) {
jobs += scope.launch {
_state.value = _state.value.copy(message = "Connecting to ${host.name}…")
@@ -0,0 +1,175 @@
package org.prolibertate.games.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.Socket
/**
* Finding the network the other phone is actually on.
*
* A phone's hotspot has no internet behind it, and Android takes that
* literally: the Wi-Fi network fails its captive-portal check, stays
* unvalidated, and **mobile data remains the default network**. Every socket
* this app opens without saying otherwise is therefore routed out over
* cellular, where 192.168.x.x means nothing at all. That is the whole of the
* "two phones on one phone's hotspot cannot see each other" bug: nothing is
* wrong with the Wi-Fi, the packets are simply being posted to the wrong
* network.
*
* The cure is to name the network explicitly rather than accept the default —
* which is why everything here deliberately looks for a Wi-Fi network *without*
* asking whether it has internet. Asking is what breaks it.
*/
/** The Wi-Fi network this device is on, internet or no internet. */
fun wifiNetwork(context: Context): Network? {
val manager = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return null
@Suppress("DEPRECATION")
return manager.allNetworks.firstOrNull { network ->
val capabilities = manager.getNetworkCapabilities(network)
capabilities != null &&
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
}
/**
* Points this socket at the network [target] is actually on, before it
* connects.
*
* Two different problems wearing the same hat, depending on which end of the
* hotspot this phone is.
*
* A phone that has *joined* a hotspot has a Wi-Fi network, and naming it is
* enough. A phone that is *sharing* one does not: its guests live on the
* tethering interface, and ConnectivityManager does not offer that as a
* [Network] anything can bind to — so the socket falls back to the default
* network, which is mobile data, and a connection to a guest goes out over
* cellular and dies. Binding this end to an address on the guest's own subnet
* picks the route instead, which is the only lever available.
*/
fun bindForTarget(context: Context, socket: Socket, target: String) {
val network = wifiNetwork(context)
if (network != null) {
runCatching { network.bindSocket(socket) }
return
}
val source = localIpv4Addresses().firstOrNull { sameSubnet(it, target) } ?: return
runCatching { socket.bind(InetSocketAddress(InetAddress.getByName(source), 0)) }
}
/**
* Whether two IPv4 addresses are on the same /24.
*
* A guess, but a safe one here: every Android hotspot hands out a /24, and the
* only thing this decides is which of our own addresses to send from. Getting
* it wrong costs a bind that would not have helped anyway.
*/
fun sameSubnet(a: String, b: String): Boolean {
val left = a.split('.')
val right = b.split('.')
if (left.size != 4 || right.size != 4) return false
return left.subList(0, 3) == right.subList(0, 3)
}
/**
* The addresses another device on the same Wi-Fi could reach this one at.
*
* Read off the interfaces rather than asked of [WifiManager], because the phone
* running the hotspot is not a Wi-Fi client and has no client address to give —
* its guests reach it at whatever the tethering interface is numbered, which
* only the interface list knows.
*/
fun localIpv4Addresses(): List<String> {
val interfaces = runCatching { NetworkInterface.getNetworkInterfaces() }
.getOrNull() ?: return emptyList()
val found = mutableListOf<Pair<Int, String>>()
for (nic in interfaces) {
if (runCatching { !nic.isUp || nic.isLoopback }.getOrDefault(true)) continue
for (address in nic.inetAddresses) {
if (address !is Inet4Address || address.isLoopbackAddress) continue
val text = address.hostAddress ?: continue
// Tethering interfaces first: on the phone running the hotspot that
// is the only address its guests can reach, and it is the case the
// list exists to serve.
val rank = when {
nic.name.startsWith("ap") || nic.name.startsWith("swlan") -> 0
nic.name.startsWith("wlan") -> 1
else -> 2
}
found += rank to text
}
}
return found.sortedBy { it.first }.map { it.second }.distinct()
}
/**
* Holds the Wi-Fi chip's multicast filter open.
*
* mDNS is multicast, and without this lock the radio drops multicast frames
* that are not addressed to this device before the system ever sees them. The
* manifest has asked for the permission since the beginning; nothing ever took
* the lock.
*/
class MulticastGuard(context: Context) {
private val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as? WifiManager
private var lock: WifiManager.MulticastLock? = null
fun acquire() {
if (lock != null) return
lock = runCatching {
wifi?.createMulticastLock("plgames-discovery")?.apply {
setReferenceCounted(false)
acquire()
}
}.getOrNull()
}
fun release() {
runCatching { lock?.takeIf { it.isHeld }?.release() }
lock = null
}
}
/** An address and port typed in by hand, once it has been made sense of. */
data class ManualAddress(val address: String, val port: Int)
/**
* Reads what somebody typed into the "connect to" box.
*
* Accepts a bare address, since [DEFAULT_HOST_PORT] means the port is usually
* the same one and asking a person to copy five more digits off another screen
* is a good way to have them type four of them. A port may still be given
* after a colon for the case where the usual one was taken.
*/
fun parseManualAddress(typed: String, defaultPort: Int = DEFAULT_HOST_PORT): ManualAddress? {
val text = typed.trim()
if (text.isEmpty()) return null
val colon = text.lastIndexOf(':')
if (colon < 0) return ManualAddress(text, defaultPort).takeIf { looksLikeHost(it.address) }
val address = text.substring(0, colon).trim()
val port = text.substring(colon + 1).trim().toIntOrNull() ?: return null
if (port !in 1..65_535) return null
return ManualAddress(address, port).takeIf { looksLikeHost(it.address) }
}
/**
* Enough of a check to catch a slip, and no more. Anything that survives this
* is handed to the socket, which is the real judge of whether it exists.
*/
private fun looksLikeHost(address: String): Boolean =
address.isNotEmpty() && address.none { it.isWhitespace() }
@@ -36,6 +36,28 @@ data class DiscoveredHost(
val port: Int = 0,
)
/**
* Where a host is listening, for reading out loud.
*
* Discovery is not always possible — a phone sharing its own hotspot does not
* reliably advertise over the tethering interface, whatever the other end does
* — so a host has to be able to say where it is in a form somebody can type
* into the other phone.
*/
data class HostEndpoint(val addresses: List<String>, val port: Int) {
/** The one to read out, tethering address first. */
val primary: String? get() = addresses.firstOrNull()?.let { "$it:$port" }
}
/**
* The port a host listens on when it can have it.
*
* Fixed rather than assigned by the system, so that joining by hand is typing
* an address and not an address and a five-digit number. A host that finds it
* taken falls back to whatever it is given and says so.
*/
const val DEFAULT_HOST_PORT = 47_654
/**
* One peer-to-peer link carrying [NetMessage]s.
*
@@ -6,24 +6,30 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.DiscoveredHost
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.seatForDevice
@@ -75,7 +81,13 @@ fun LobbyScreen(
val title = if (route.hosting) "Hosting" else "Join a game"
ScreenScaffold(title = title, onBack = onBack) { modifier ->
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Scrolls rather than filling: a lazy list here took the whole screen
// and pushed the Start button — and now the address to type in — off
// the bottom of it. There are never more than a handful of rows.
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val available = env.lobby.availableTransports()
Text(
text = if (available.isEmpty()) {
@@ -94,7 +106,7 @@ fun LobbyScreen(
if (route.hosting) {
HostingBody(env.lobby, state)
} else {
JoiningBody(env, state.discovered, playerName)
JoiningBody(env, state, playerName)
}
}
}
@@ -102,12 +114,26 @@ fun LobbyScreen(
@Composable
private fun HostingBody(lobby: LobbyController, state: LobbyController.State) {
// Where to find this phone, for when the other one cannot find it by
// itself. Over a phone's own hotspot that is the normal case rather than
// the exception, so this is stated plainly rather than tucked away.
state.endpoint?.primary?.let { where ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Text("This phone is at", style = MaterialTheme.typography.labelSmall)
Text(where, fontWeight = FontWeight.Bold)
Text(
text = "If the other phone doesn't find the game on its own — which " +
"happens when you're sharing this phone's hotspot — type that " +
"into its Join screen.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
Text("Seats", fontWeight = FontWeight.Bold)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
items(state.seats) { slot ->
state.seats.forEach { slot ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
@@ -133,7 +159,6 @@ private fun HostingBody(lobby: LobbyController, state: LobbyController.State) {
}
}
}
}
PrimaryAction(
text = "Start game",
@@ -150,20 +175,21 @@ private fun HostingBody(lobby: LobbyController, state: LobbyController.State) {
@Composable
private fun JoiningBody(
env: AppEnv,
discovered: List<DiscoveredHost>,
state: LobbyController.State,
playerName: String,
) {
val discovered = state.discovered
if (discovered.isEmpty()) {
Text("No games found yet. Make sure the host has started theirs.")
Text(
text = "Both devices need to be on the same Wi-Fi network.",
text = "Both phones need to be on the same Wi-Fi — including one phone's " +
"hotspot, though over a hotspot the game often has to be joined by " +
"address rather than found.",
style = MaterialTheme.typography.bodySmall,
)
return
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(discovered) { host ->
} else {
discovered.forEach { host ->
Card(
modifier = Modifier
.fillMaxWidth()
@@ -179,4 +205,44 @@ private fun JoiningBody(
}
}
}
Divider()
ManualJoin(env, playerName)
}
/**
* Joining by address.
*
* Always offered rather than kept for when discovery has failed, because over a
* phone's hotspot discovery does not fail loudly — it just never finds
* anything, and a screen that only says "no games found" gives no way forward.
*/
@Composable
private fun ManualJoin(env: AppEnv, playerName: String) {
var typed by remember { mutableStateOf("") }
Text("Join by address", fontWeight = FontWeight.Bold)
Text(
text = "The host's screen shows where it is. The port can be left off unless " +
"the host is showing a different one.",
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
label = { Text("192.168.43.1") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.weight(1f),
)
Button(
onClick = { env.lobby.joinAt(typed, playerName, env.peerId) },
enabled = typed.isNotBlank(),
) { Text("Connect") }
}
}
@@ -0,0 +1,136 @@
package org.prolibertate.games.net
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reading an address somebody has typed off another phone's screen.
*
* This is the way into a game when discovery cannot work, which over a phone's
* own hotspot is the normal case — so it has to accept what a person will
* actually type, including the trailing space the keyboard adds and the port
* they will usually leave off.
*/
class ManualAddressTest {
@Test
fun `a bare address gets the usual port`() {
assertEquals(
ManualAddress("192.168.43.1", DEFAULT_HOST_PORT),
parseManualAddress("192.168.43.1"),
)
}
@Test
fun `a port after a colon is used instead`() {
assertEquals(
ManualAddress("192.168.43.1", 50000),
parseManualAddress("192.168.43.1:50000"),
)
}
/** Typing on a phone puts spaces in places nobody intended. */
@Test
fun `surrounding space is ignored`() {
assertEquals(
ManualAddress("192.168.1.20", DEFAULT_HOST_PORT),
parseManualAddress(" 192.168.1.20 "),
)
assertEquals(
ManualAddress("192.168.1.20", 47654),
parseManualAddress(" 192.168.1.20 : 47654 "),
)
}
@Test
fun `a hostname works as well as an address`() {
assertEquals(
ManualAddress("pixel-9.local", DEFAULT_HOST_PORT),
parseManualAddress("pixel-9.local"),
)
}
@Test
fun `nothing typed is nothing to connect to`() {
assertNull(parseManualAddress(""))
assertNull(parseManualAddress(" "))
}
@Test
fun `a port that is not a number is refused rather than guessed`() {
assertNull(parseManualAddress("192.168.43.1:door"))
assertNull(parseManualAddress("192.168.43.1:"))
}
@Test
fun `a port outside the range is refused`() {
assertNull(parseManualAddress("192.168.43.1:0"))
assertNull(parseManualAddress("192.168.43.1:70000"))
assertNull(parseManualAddress("192.168.43.1:-1"))
}
@Test
fun `an address with a hole in it is refused`() {
assertNull(parseManualAddress(":47654"))
assertNull(parseManualAddress("192.168.43.1 20:47654"))
}
/**
* The default is only a default. A host that found the usual port taken
* shows a different one, and that is what gets typed.
*/
@Test
fun `the default port is only used when none is given`() {
assertEquals(DEFAULT_HOST_PORT, parseManualAddress("10.0.0.5")?.port)
assertEquals(1234, parseManualAddress("10.0.0.5:1234")?.port)
}
@Test
fun `an endpoint reads out as address and port`() {
val endpoint = HostEndpoint(listOf("192.168.43.1", "10.0.0.5"), DEFAULT_HOST_PORT)
assertEquals("192.168.43.1:$DEFAULT_HOST_PORT", endpoint.primary)
}
@Test
fun `an endpoint with no addresses has nothing to read out`() {
assertNull(HostEndpoint(emptyList(), DEFAULT_HOST_PORT).primary)
}
/** What the host shows must be what the joiner can type. */
@Test
fun `what a host shows is what the other end accepts`() {
val shown = HostEndpoint(listOf("192.168.43.1"), 47654).primary!!
assertEquals(ManualAddress("192.168.43.1", 47654), parseManualAddress(shown))
}
// -- Choosing which of our own addresses to send from --------------------
/**
* The phone sharing the hotspot has no Wi-Fi network to bind to, so it
* picks a source address on the guest's subnet instead. Getting this wrong
* sends the connection out over mobile data.
*/
@Test
fun `addresses on one hotspot subnet match`() {
assertTrue(sameSubnet("192.168.43.1", "192.168.43.117"))
assertTrue(sameSubnet("192.168.1.20", "192.168.1.20"))
}
@Test
fun `addresses on different subnets do not`() {
assertFalse(sameSubnet("192.168.43.1", "192.168.1.5"))
assertFalse(sameSubnet("192.168.43.1", "10.0.0.5"))
}
/** Anything that is not a dotted quad is not a match, rather than a crash. */
@Test
fun `nonsense is not a match`() {
assertFalse(sameSubnet("", "192.168.43.1"))
assertFalse(sameSubnet("192.168.43", "192.168.43.1"))
assertFalse(sameSubnet("pixel-9.local", "192.168.43.1"))
assertFalse(sameSubnet("192.168.43.1.7", "192.168.43.1"))
}
}