Make LAN play work over a phone's hotspot

Two phones on one phone's hotspot could not find or reach each other.
Nothing was wrong with the Wi-Fi. There were three faults, and the first
one is the whole bug.

A hotspot has no internet behind it, and Android takes that literally:
the Wi-Fi network fails its connectivity check, stays unvalidated, and
mobile data remains the *default* network. Every socket opened without
naming a network is therefore routed out over cellular — where
192.168.43.1 means nothing. The join socket is now bound to the Wi-Fi
network before it connects, and the search for that network deliberately
does not ask whether it has internet, because asking is what breaks it.

Second, the manifest has asked for CHANGE_WIFI_MULTICAST_STATE since the
beginning, with a comment saying discovery needs it, and nothing ever
took the lock. Without it the radio drops multicast frames not addressed
to this device before the system sees them, and mDNS is multicast. Both
advertising and discovering now hold it.

Third, and not fixable: a phone sharing its own hotspot does not
reliably advertise over the tethering interface, whatever the other end
does. So the host now shows where it is and the joiner can type it in.
That path is offered always rather than kept back for when discovery has
failed, because discovery here does not fail loudly — it just never
finds anything, and a screen saying only "no games found" leaves nowhere
to go. Hosts take a known port when they can get one, so what gets typed
is an address and not an address and five more digits.

The lobby scrolls instead of filling. Its lazy lists took the whole
screen for a handful of rows and pushed the Start button off the bottom;
they would have done the same to the address box.

Parsing what somebody types off another phone's screen is the one pure
piece of this and is tested, including that what a host displays is
exactly what the other end accepts. The rest needs two phones and a
hotspot, which CI does not have.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
This commit is contained in:
Claude
2026-08-15 23:52:39 +00:00
parent cba66906bb
commit f8f02dd93e
6 changed files with 452 additions and 42 deletions
@@ -0,0 +1,106 @@
package org.prolibertate.games.net
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
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))
}
}