Scorekeeper sharing: join from any sheet, show waiting requests, diagnostics

A guest was timing out with nothing ever shown on the host. Without a
device to look at, this makes the next test say where it stops, and fixes
what was found on the way.

- The host's Sharing panel shows links received and join requests read, which
  tells "nobody reached this phone" from "reached it and not understood".
- The guest says when its link is up and it is waiting for an answer.
- A request waiting shows on the host's top bar ("Sharing . 1 waiting"), and
  the Sharing panel steps aside while one is up so two dialogs never stack.
- Join is a top-bar action on a sheet already under way; it was only offered
  on an empty one.
- LanTransport: a host being cleaned up no longer unregisters the
  advertisement or clears the address of a newer host started in its place.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
This commit is contained in:
James Wallace
2026-10-10 02:56:36 +00:00
co-authored by Claude Sonnet 5.5
parent 3e277754e9
commit d9fdb598ac
4 changed files with 36 additions and 4 deletions
@@ -135,11 +135,19 @@ class LanTransport(private val context: Context) : Transport {
awaitClose {
acceptJob.cancel()
stopAdvertising()
// Only what this call set up. Hosting stopped and started again
// runs this for the old host after the new one has begun, and
// letting it clear the shared fields would unregister the new
// host's advertisement and blank the address it shows.
runCatching { nsdManager.unregisterService(listener) }
if (registrationListener === listener) registrationListener = null
runCatching { server.close() }
if (serverSocket === server) {
serverSocket = null
_endpoint.value = null
}
}
}
// -----------------------------------------------------------------------
// Discovery
@@ -70,6 +70,13 @@ class ScorekeeperSession(
val discovered: List<DiscoveredHost> = emptyList(),
/** Host: where this device is listening, for reading out. */
val endpoint: HostEndpoint? = null,
/**
* Host: links accepted, and hellos read off them. For telling "nobody
* reached this phone" from "they reached it and were not understood",
* which look the same from the other end.
*/
val connections: Int = 0,
val hellos: Int = 0,
val message: String? = null,
)
@@ -142,6 +149,7 @@ class ScorekeeperSession(
}
private fun listenToGuest(connection: Connection) {
_state.update { it.copy(connections = it.connections + 1) }
jobs += scope.launch {
connection.incoming.collect { message ->
synchronized(lock) { onHostMessage(connection, message) }
@@ -192,6 +200,7 @@ class ScorekeeperSession(
}
private fun onHello(connection: Connection, hello: Hello) {
_state.update { it.copy(hellos = it.hellos + 1) }
val refusal = when {
hello.purpose != PURPOSE_SCOREKEEPER -> "That is a score sheet, not a game."
hello.protocol != PROTOCOL_VERSION -> "Different app version — update both devices."
@@ -377,6 +386,7 @@ class ScorekeeperSession(
link = connection
heardBack = false
replica.reset()
_state.update { it.copy(message = "Connected. Waiting for ${it.hostName} to answer…") }
}
val hello = Hello(peerId = deviceId, displayName = joinedName, purpose = PURPOSE_SCOREKEEPER)
linkJob = scope.launch {
@@ -173,6 +173,9 @@ fun ScorekeeperScreen(
actions = {
when (role) {
Role.NONE -> if (sheet.started && !joining) {
// A sheet already on the go is no reason to hide the way to
// somebody else's.
TextButton(onClick = { joining = true }) { Text("Join") }
TextButton(onClick = {
session.startHosting(playerName, local)
showShare = true
@@ -182,7 +185,13 @@ fun ScorekeeperScreen(
Role.HOST -> {
TextButton(onClick = { showShare = true }) {
Text(if (shared.guests.isEmpty()) "Sharing" else "Sharing (${shared.guests.size})")
Text(
when {
shared.requests.isNotEmpty() -> "Sharing · ${shared.requests.size} waiting"
shared.guests.isNotEmpty() -> "Sharing (${shared.guests.size})"
else -> "Sharing"
}
)
}
if (sheet.started) {
TextButton(onClick = { confirmNewGame = true }) { Text("New game") }
@@ -259,7 +268,8 @@ fun ScorekeeperScreen(
onDeny = { session.deny(request.id) },
)
}
if (showShare) {
// Not under a request: two dialogs stacked is how an answer gets missed.
if (showShare && shared.requests.isEmpty()) {
ShareDialog(
shared = shared,
onRemove = { session.remove(it) },
@@ -210,6 +210,10 @@ internal fun ShareDialog(
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = "Links received: ${shared.connections} · join requests: ${shared.hellos}",
style = MaterialTheme.typography.labelSmall,
)
Divider()
Text("On the sheet", fontWeight = FontWeight.Bold)
if (shared.guests.isEmpty()) {