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:
@@ -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,34 +114,47 @@ 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 ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text("Seat ${slot.seat} — ${slot.name}")
|
||||
Text(
|
||||
text = when (slot.kind) {
|
||||
PlayerKind.HUMAN_LOCAL -> "You"
|
||||
PlayerKind.HUMAN_REMOTE -> "Joined"
|
||||
PlayerKind.AI -> "Computer"
|
||||
} + " · Team ${slot.team}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (slot.kind == PlayerKind.HUMAN_REMOTE) {
|
||||
TextButton(onClick = {
|
||||
lobby.setSeatKind(slot.seat, PlayerKind.AI, "Computer ${slot.seat}")
|
||||
}) { Text("Drop") }
|
||||
}
|
||||
state.seats.forEach { slot ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text("Seat ${slot.seat} — ${slot.name}")
|
||||
Text(
|
||||
text = when (slot.kind) {
|
||||
PlayerKind.HUMAN_LOCAL -> "You"
|
||||
PlayerKind.HUMAN_REMOTE -> "Joined"
|
||||
PlayerKind.AI -> "Computer"
|
||||
} + " · Team ${slot.team}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (slot.kind == PlayerKind.HUMAN_REMOTE) {
|
||||
TextButton(onClick = {
|
||||
lobby.setSeatKind(slot.seat, PlayerKind.AI, "Computer ${slot.seat}")
|
||||
}) { Text("Drop") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user