From d9fdb598ac494c2fda4e89017a5f6c4750327d08 Mon Sep 17 00:00:00 2001 From: James Wallace Date: Sat, 10 Oct 2026 02:56:36 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G --- .../org/prolibertate/games/net/LanTransport.kt | 12 ++++++++++-- .../prolibertate/games/net/ScorekeeperSession.kt | 10 ++++++++++ .../org/prolibertate/games/ui/ScorekeeperScreen.kt | 14 ++++++++++++-- .../prolibertate/games/ui/ScorekeeperShareUi.kt | 4 ++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/net/LanTransport.kt b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt index 0d1c6e4..1976c05 100644 --- a/app/src/main/java/org/prolibertate/games/net/LanTransport.kt +++ b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt @@ -135,9 +135,17 @@ 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() } - _endpoint.value = null + if (serverSocket === server) { + serverSocket = null + _endpoint.value = null + } } } diff --git a/app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt b/app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt index 850485e..15fc8e9 100644 --- a/app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt +++ b/app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt @@ -70,6 +70,13 @@ class ScorekeeperSession( val discovered: List = 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 { diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt index e98873f..ee5a203 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -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) }, diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt index 85acf21..6015589 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt @@ -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()) {