Scorekeeper: share a sheet between devices

A host can share its score sheet and others nearby can join it, found the
same way games are, or by typed address over a hotspot.

- A joining guest is not let in on its own say-so. The host is prompted with
  the guest's name and answers Allow or Deny. A device already let in that
  drops out (locked phone) is let straight back; the host can remove anyone.
- The host's sheet is authoritative. What travels is what was done (SheetOp:
  add round, rename, move column, ...), not whole sheets, so changes made at
  the same moment all land. A guest's own changes show at once and are laid
  over the host's sheet until confirmed (SheetReplica).
- Sync asks the host for the sheet as it stands, and runs on its own when
  the app returns to the front, as it does for a game table.
- Hello carries a purpose and hosts advertise one, so a game join screen does
  not list score sheets and a game guest typing a sheet's address is told so.
- The Scorekeeper screen now expresses every edit as a SheetOp, so one grid
  serves a local sheet, a hosted one and a joined one.

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 01:27:40 +00:00
co-authored by Claude Sonnet 5.5
parent 16f9321ca3
commit 89b19a894a
14 changed files with 1807 additions and 38 deletions
+20
View File
@@ -79,6 +79,26 @@ Points are taken away by typing a minus, or with the **±** button, which flips
the last number typed. Not every keyboard offers a minus on its number pad, and
subtracting has to work on all of them.
### Sharing a sheet between devices
Tap **Share** and this phone becomes the host of its sheet; others choose
**Scorekeeper → Join a shared sheet nearby**. Discovery and joining by address
work exactly as they do for a game, hotspot included. Nobody is let in on their
own say-so: the host gets a prompt with the guest's name and has to **Allow** or
**Deny** it. A guest who was let in and drops out — a locked phone, say — is let
straight back in rather than asked again, and the host can **Remove** anybody
from the Sharing panel.
Everyone on the sheet can add rounds, correct them, rename and move columns. The
host's copy is the truth, and what travels is what was *done* ("add a round
with these points"), not whole sheets, so two people entering a round at once
get two rounds rather than one overwriting the other. A guest's own change shows
immediately and is laid over whatever the host sends until the host confirms it.
**Sync** asks the host for the sheet as it stands, and happens by itself when the
app comes back to the front. The host keeps the sheet as its own saved one;
a guest's view is not saved, and leaving puts them back on their own sheet. Only
the host can start a new game.
The sheet is saved as it is edited, so leaving the app or taking a phone call
does not lose the game. It is kept in its own DataStore rather than with the
settings, because it is a game in progress rather than a preference.
@@ -5,7 +5,9 @@ import android.provider.Settings as AndroidSettings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import org.prolibertate.games.net.LanTransport
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.AppEnv
@@ -21,12 +23,23 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val peerId = stablePeerId()
// Its own transport rather than the lobby's: both listen on the same
// port, and a screen at a time is only ever using one of them.
val scoreLan = LanTransport(applicationContext)
env = AppEnv(
settingsRepository = SettingsRepository(applicationContext),
updater = Updater(this),
lobby = LobbyController(applicationContext, lifecycleScope),
scorekeeper = ScorekeeperRepository(applicationContext),
peerId = stablePeerId(),
scoreSession = ScorekeeperSession(
transport = scoreLan,
endpoint = scoreLan.endpoint,
scope = lifecycleScope,
deviceId = peerId,
),
peerId = peerId,
)
setContent {
@@ -50,6 +63,9 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
super.onDestroy()
if (::env.isInitialized) env.lobby.stop()
if (::env.isInitialized) {
env.lobby.stop()
env.scoreSession.stop()
}
}
}
@@ -83,7 +83,11 @@ class LanTransport(private val context: Context) : Transport {
// Hosting
// -----------------------------------------------------------------------
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
override fun host(
displayName: String,
scope: CoroutineScope,
purpose: HostPurpose,
): Flow<Connection> = callbackFlow {
holdRadio()
// The known port when it can be had, so that joining by hand needs an
@@ -97,7 +101,7 @@ class LanTransport(private val context: Context) : Transport {
multicast.acquire()
val serviceInfo = NsdServiceInfo().apply {
serviceName = "$SERVICE_NAME @ $displayName"
serviceName = "${serviceLabel(purpose)} @ $displayName"
serviceType = SERVICE_TYPE
port = server.localPort
}
@@ -175,6 +179,7 @@ class LanTransport(private val context: Context) : Transport {
kind = TransportKind.LAN,
address = address,
port = info.port,
purpose = purposeOf(info.serviceName),
)
synchronized(found) { found[host.id] = host }
publish()
@@ -278,6 +283,22 @@ class LanTransport(private val context: Context) : Transport {
}
private companion object {
/**
* The service name is the only thing a host says about itself before
* anyone has connected, so what it is hosting goes in it.
*/
const val SCOREKEEPER_LABEL = "$SERVICE_NAME Scorekeeper"
fun serviceLabel(purpose: HostPurpose): String =
if (purpose == HostPurpose.SCOREKEEPER) SCOREKEEPER_LABEL else SERVICE_NAME
fun purposeOf(serviceName: String?): HostPurpose =
if (serviceName?.startsWith("$SCOREKEEPER_LABEL @") == true) {
HostPurpose.SCOREKEEPER
} else {
HostPurpose.GAME
}
const val CONNECT_TIMEOUT_MS = 8_000
const val WIFI_LOCK_TAG = "ProLibertateGames:lan"
}
@@ -129,6 +129,18 @@ class LobbyController(
jobs += scope.launch {
connection.incoming.collect { message ->
if (message !is Hello) return@collect
if (message.purpose != PURPOSE_GAME) {
connection.send(
Welcome(
hostName = _state.value.hostName,
gameId = _state.value.gameId.orEmpty(),
accepted = false,
reason = "That is a game, not a score sheet.",
)
)
connection.close()
return@collect
}
if (message.protocol != PROTOCOL_VERSION) {
connection.send(
Welcome(
@@ -257,7 +269,9 @@ class LobbyController(
_state.value = State(joining = true, message = "Looking for games…")
for (transport in availableTransports()) {
jobs += scope.launch {
transport.discover(scope).collect { hosts ->
transport.discover(scope).collect { found ->
// Score sheets are joined from the Scorekeeper, not from here.
val hosts = found.filter { it.purpose == HostPurpose.GAME }
val others = _state.value.discovered.filter { it.kind != transport.kind }
_state.value = _state.value.copy(discovered = others + hosts)
}
@@ -6,6 +6,8 @@ import kotlinx.serialization.json.Json
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.score.ScoreSheet
import org.prolibertate.games.score.SheetOp
/**
* The wire protocol, shared by every transport.
@@ -25,6 +27,10 @@ import org.prolibertate.games.game.engine.TableConfig
*/
const val PROTOCOL_VERSION = 2
/** What a [Hello] is asking to join. Absent means a game, which is all there was before. */
const val PURPOSE_GAME = "game"
const val PURPOSE_SCOREKEEPER = "scorekeeper"
/** Service type and name advertised over mDNS. */
const val SERVICE_TYPE = "_plgames._tcp"
const val SERVICE_NAME = "ProLibertateGames"
@@ -63,6 +69,13 @@ data class Hello(
val peerId: String,
val displayName: String,
val protocol: Int = PROTOCOL_VERSION,
/**
* Whether this is somebody joining a game or a shared score sheet. Both
* listen the same way, so a phone pointed at the wrong sort of host by a typed
* address needs a way to be told so rather than waiting for a table that will
* never start.
*/
val purpose: String = PURPOSE_GAME,
) : NetMessage
/** Host's reply, or a refusal if the lobby is full or the versions differ. */
@@ -150,3 +163,26 @@ data object Pong : NetMessage
@Serializable
@SerialName("bye")
data object Bye : NetMessage
/**
* The host's score sheet, sent to a guest on joining, whenever it changes, and
* whenever the guest asks with [Resync].
*
* The whole sheet rather than the change: a sheet is a few hundred bytes, and a
* guest that has missed a message is put right by the next one instead of
* needing to be told what it missed. [acknowledged] is the last [SheetEdit] from
* *this* guest that the sheet already includes, which is how the guest knows
* which of its own changes it may stop holding on top of it.
*/
@Serializable
@SerialName("sheet")
data class SheetState(
val revision: Int,
val sheet: ScoreSheet,
val acknowledged: Int = 0,
) : NetMessage
/** A guest's change to the shared sheet. The host applies it, then answers with [SheetState]. */
@Serializable
@SerialName("sheet-edit")
data class SheetEdit(val seq: Int, val op: SheetOp) : NetMessage
@@ -0,0 +1,564 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.prolibertate.games.score.ScoreSheet
import org.prolibertate.games.score.SheetOp
import org.prolibertate.games.score.SheetReplica
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
/**
* One score sheet shared between several devices, in either of two roles.
*
* As HOST this device owns the sheet. Everybody's changes — its own and its
* guests' — are applied to it here, in the order they arrive, and the result is
* sent back out. As GUEST it holds what the host last sent, with its own
* unacknowledged changes laid on top (see [SheetReplica]), and sends changes
* rather than sheets.
*
* A guest is not let in on its own say-so. Its [Hello] lands in [State.requests]
* and waits there until somebody on the host answers it with [allow] or [deny];
* nothing the guest sends in the meantime is looked at. A device that has already
* been let in is let back in without asking again, because a phone that was
* locked for a minute is not a new person.
*
* All the state is changed synchronously, under one lock, and the messages that
* follow from it are queued and sent in order by one coroutine. The first half
* is what lets a keystroke show on screen the moment it is typed; the second is
* what stops a guest being sent a sheet before it has been sent its welcome.
*/
class ScorekeeperSession(
private val transport: Transport,
private val endpoint: StateFlow<HostEndpoint?>,
private val scope: CoroutineScope,
/** Identifies this device across reconnects, so a returning guest is recognised. */
private val deviceId: String,
) {
enum class Role { NONE, HOST, GUEST }
/** A guest the host has let in. [online] is false while its link is down. */
data class Guest(val id: String, val name: String, val online: Boolean)
/** Somebody asking to be let in. */
data class Request(val id: String, val name: String)
data class State(
val role: Role = Role.NONE,
val hostName: String = "",
/** The sheet being shared: the host's own, or a guest's view of it. */
val sheet: ScoreSheet = ScoreSheet(),
/** Guest: let in, and the link to the host is up. */
val connected: Boolean = false,
/** Guest: asked to join and not yet answered. */
val awaiting: Boolean = false,
/** Guest: turned away, or removed. Nothing will be retried. */
val refused: Boolean = false,
val guests: List<Guest> = emptyList(),
val requests: List<Request> = emptyList(),
val searching: Boolean = false,
val discovered: List<DiscoveredHost> = emptyList(),
/** Host: where this device is listening, for reading out. */
val endpoint: HostEndpoint? = null,
val message: String? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
private val lock = Any()
private val jobs = CopyOnWriteArrayList<Job>()
private var discoveryJob: Job? = null
/** Everything that goes out, in order. See the class comment. */
private val outbox = Channel<suspend () -> Unit>(Channel.UNLIMITED)
init {
scope.launch {
for (task in outbox) runCatching { task() }
}
}
private fun enqueue(connection: Connection, message: NetMessage) {
outbox.trySend { connection.send(message) }
}
private fun enqueueClose(connection: Connection) {
outbox.trySend { connection.close() }
}
// -----------------------------------------------------------------------
// Host
// -----------------------------------------------------------------------
/** A guest that has been let in. Its [connection] is null while the link is down. */
private class HostLink(
val deviceId: String,
var name: String,
var connection: Connection?,
/** The last change from this guest, on this link, that has been applied. */
var lastSeq: Int = 0,
)
private val admitted = linkedMapOf<String, HostLink>()
private val waiting = linkedMapOf<String, Pair<String, Connection>>()
private var hostSheet = ScoreSheet()
private var revision = 0
private var hostName = ""
/** Starts sharing [sheet] with whoever is allowed to join. */
fun startHosting(name: String, sheet: ScoreSheet) {
stop()
synchronized(lock) {
hostName = name
hostSheet = sheet
revision = 0
_state.value = State(
role = Role.HOST,
hostName = name,
sheet = sheet,
endpoint = endpoint.value,
message = "Waiting for others to join…",
)
}
jobs += scope.launch {
transport.host(name, scope, HostPurpose.SCOREKEEPER).collect { connection ->
listenToGuest(connection)
}
}
jobs += scope.launch {
endpoint.collect { where -> _state.update { it.copy(endpoint = where) } }
}
}
private fun listenToGuest(connection: Connection) {
jobs += scope.launch {
connection.incoming.collect { message ->
synchronized(lock) { onHostMessage(connection, message) }
}
}
}
private fun linkFor(connection: Connection): HostLink? =
admitted.values.firstOrNull { it.connection === connection }
private fun onHostMessage(connection: Connection, message: NetMessage) {
if (_state.value.role != Role.HOST) return
when (message) {
is Hello -> onHello(connection, message)
is SheetEdit -> {
val link = linkFor(connection) ?: return
// A change already applied is answered rather than applied twice.
if (message.seq > link.lastSeq) {
link.lastSeq = message.seq
val next = message.op.applyTo(hostSheet)
if (next != hostSheet) {
hostSheet = next
revision++
}
}
broadcast()
}
is Resync -> linkFor(connection)?.let { sendSheet(it) }
is Bye -> {
val link = linkFor(connection)
if (link != null) {
link.connection = null
publishHost("${link.name} lost their connection.")
} else {
val gone = waiting.filterValues { it.second === connection }.keys
if (gone.isNotEmpty()) {
gone.toList().forEach { waiting.remove(it) }
publishHost(null)
}
}
}
else -> Unit
}
}
private fun onHello(connection: Connection, hello: Hello) {
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."
else -> null
}
if (refusal != null) {
enqueue(connection, Welcome(hostName, "", accepted = false, reason = refusal))
enqueueClose(connection)
return
}
val known = admitted[hello.peerId]
if (known != null) {
// Somebody already let in, back on a new link — or saying hello a
// second time on the one it has, which is what a guest does until it
// hears something. Either way it is put straight back.
val stale = known.connection
if (stale != null && stale !== connection) enqueueClose(stale)
if (known.connection !== connection) known.lastSeq = 0
known.connection = connection
known.name = hello.displayName
enqueue(connection, Welcome(hostName, "", accepted = true))
sendSheet(known)
publishHost("${known.name} is back.")
return
}
if (admitted.size >= MAX_GUESTS) {
enqueue(connection, Welcome(hostName, "", accepted = false, reason = "The sheet is full."))
enqueueClose(connection)
return
}
val earlier = waiting[hello.peerId]?.second
if (earlier != null && earlier !== connection) enqueueClose(earlier)
waiting[hello.peerId] = hello.displayName to connection
publishHost("${hello.displayName} wants to join.")
}
/** Host: lets a waiting guest in. */
fun allow(requestId: String) = synchronized(lock) {
val (name, connection) = waiting.remove(requestId) ?: return@synchronized
val link = HostLink(requestId, name, connection)
admitted[requestId] = link
enqueue(connection, Welcome(hostName, "", accepted = true))
sendSheet(link)
publishHost("$name joined.")
}
/** Host: turns a waiting guest away. */
fun deny(requestId: String) = synchronized(lock) {
val (name, connection) = waiting.remove(requestId) ?: return@synchronized
enqueue(connection, Welcome(hostName, "", accepted = false, reason = "The host declined."))
enqueueClose(connection)
publishHost("Turned $name away.")
}
/** Host: takes a guest who was let in back out. They are asked again if they return. */
fun remove(guestId: String) = synchronized(lock) {
val link = admitted.remove(guestId) ?: return@synchronized
link.connection?.let {
enqueue(it, Welcome(hostName, "", accepted = false, reason = "The host removed you."))
enqueueClose(it)
}
publishHost("${link.name} was removed.")
}
private fun sendSheet(link: HostLink) {
link.connection?.let { enqueue(it, SheetState(revision, hostSheet, link.lastSeq)) }
}
private fun broadcast() {
admitted.values.forEach { sendSheet(it) }
publishHost(_state.value.message)
}
private fun publishHost(message: String?) {
_state.update {
it.copy(
sheet = hostSheet,
guests = admitted.values.map { g -> Guest(g.deviceId, g.name, g.connection != null) },
requests = waiting.map { (id, entry) -> Request(id, entry.first) },
message = message,
)
}
}
// -----------------------------------------------------------------------
// Guest
// -----------------------------------------------------------------------
private val replica = SheetReplica()
private var link: Connection? = null
private var linkJob: Job? = null
private var joined: DiscoveredHost? = null
private var joinedName = ""
@Volatile
private var heardBack = false
private var refused = false
private val reconnecting = AtomicBoolean(false)
/** Starts looking for sheets being shared nearby. */
fun startDiscovery() {
stop()
_state.value = State(searching = true, message = "Looking for shared sheets…")
discoveryJob = scope.launch {
transport.discover(scope).collect { found ->
val sheets = found.filter { it.purpose == HostPurpose.SCOREKEEPER }
_state.update { it.copy(discovered = sheets) }
}
}
}
/** Joins a host at an address somebody typed in, as [LobbyController.joinAt] does. */
fun joinAt(typed: String, name: String) {
val parsed = parseManualAddress(typed)
if (parsed == null) {
_state.update { it.copy(message = "That doesn't look like an address. Try 192.168.43.1") }
return
}
join(
DiscoveredHost(
id = "lan:${parsed.address}:${parsed.port}",
name = parsed.address,
kind = TransportKind.LAN,
address = parsed.address,
port = parsed.port,
purpose = HostPurpose.SCOREKEEPER,
),
name,
)
}
fun join(host: DiscoveredHost, name: String) {
discoveryJob?.cancel()
discoveryJob = null
synchronized(lock) {
joined = host
joinedName = name
heardBack = false
refused = false
replica.reset()
_state.value = State(
role = Role.GUEST,
hostName = host.name,
awaiting = true,
message = "Connecting to ${host.name}…",
)
}
jobs += scope.launch {
val connection = dial(host)
if (connection == null) {
// Back to the list, with the reason on it.
val why = _state.value.message
startDiscovery()
_state.update { it.copy(message = why) }
return@launch
}
attach(connection)
}
}
private suspend fun dial(host: DiscoveredHost): Connection? =
runCatching { transport.join(host, scope) }.getOrElse { error ->
_state.update { it.copy(message = "Couldn't connect: ${error.message}") }
null
}
/**
* Starts reading [connection] as the way to the host, and introduces this
* device on it.
*
* The hello is sent from inside the subscription, so it cannot leave before
* anything is listening for the answer. It is then repeated until the host
* has said something, because a host that has only just accepted the link
* may not be listening yet either, and a hello nobody heard is a guest
* waiting for an answer to a question that was never asked.
*/
private fun attach(connection: Connection) {
synchronized(lock) {
linkJob?.cancel()
link?.let { old -> if (old !== connection) enqueueClose(old) }
link = connection
heardBack = false
replica.reset()
}
val hello = Hello(peerId = deviceId, displayName = joinedName, purpose = PURPOSE_SCOREKEEPER)
linkJob = scope.launch {
launch {
var tries = 0
while (isActive && !heardBack && connection.isOpen && tries++ < HELLO_TRIES) {
delay(HELLO_RETRY_MILLIS)
if (!heardBack && connection.isOpen) connection.send(hello)
}
}
connection.incoming
.onSubscription { connection.send(hello) }
.collect { message -> synchronized(lock) { onGuestMessage(connection, message) } }
}
}
private fun onGuestMessage(connection: Connection, message: NetMessage) {
if (connection !== link || _state.value.role != Role.GUEST) return
when (message) {
is Welcome -> {
heardBack = true
if (message.accepted) {
_state.update {
it.copy(
hostName = message.hostName.ifBlank { it.hostName },
awaiting = false,
connected = true,
message = "Joined ${message.hostName.ifBlank { it.hostName }}.",
)
}
} else {
refused = true
enqueueClose(connection)
_state.update {
it.copy(
awaiting = false,
connected = false,
refused = true,
message = message.reason ?: "The host declined.",
)
}
}
}
is SheetState -> {
if (refused) return
heardBack = true
replica.receive(message.sheet, message.revision, message.acknowledged)
_state.update { it.copy(sheet = replica.view) }
}
is Bye -> {
// A refusal closes the link too, and the Bye that follows it
// must not turn into an attempt to get back in.
if (refused) return
_state.update {
it.copy(connected = false, message = "Lost the connection to the host.")
}
reconnectLater()
}
else -> Unit
}
}
/**
* Guest: asks the host for the sheet as it stands, or gets back to the host
* if the link has gone.
*
* Always safe, and cheap when nothing is wrong — a live link is sent one
* [Resync] and answered with the sheet it already has.
*/
fun sync() {
val live = synchronized(lock) {
if (_state.value.role != Role.GUEST || refused) return
link
}
if (live != null && live.isOpen) {
enqueue(live, Resync)
} else {
reconnectLater()
}
}
private fun reconnectLater() {
jobs += scope.launch { reconnect() }
}
private suspend fun reconnect() {
val host = synchronized(lock) { if (refused) null else joined } ?: return
if (!reconnecting.compareAndSet(false, true)) return
try {
repeat(RECONNECT_ATTEMPTS) { attempt ->
if (attempt > 0) delay(RECONNECT_BACKOFF_MILLIS * attempt)
if (synchronized(lock) { refused || _state.value.role != Role.GUEST }) return
_state.update { it.copy(message = "Reconnecting to ${host.name}…") }
val connection = dial(host)
if (connection != null) {
attach(connection)
return
}
}
_state.update { it.copy(message = "Couldn't get back to ${host.name}.") }
} finally {
reconnecting.set(false)
}
}
// -----------------------------------------------------------------------
// Either role
// -----------------------------------------------------------------------
/**
* Makes a change to the sheet being shared.
*
* Applied here before it returns, so the screen draws it at once. As host it
* is then sent to everyone; as guest it is sent to the host, which may
* disagree — the sheet it sends back is the answer.
*/
fun edit(op: SheetOp) {
synchronized(lock) {
when (_state.value.role) {
Role.HOST -> {
val next = op.applyTo(hostSheet)
if (next != hostSheet) {
hostSheet = next
revision++
broadcast()
}
}
Role.GUEST -> {
val live = link
if (!_state.value.connected || live == null || !live.isOpen) {
_state.update {
it.copy(message = "Not connected to the host. Tap Sync to reconnect.")
}
return
}
val seq = replica.local(op)
_state.update { it.copy(sheet = replica.view) }
enqueue(live, SheetEdit(seq, op))
}
Role.NONE -> Unit
}
}
}
/** Stops hosting, joining or looking, and lets go of every link. */
fun stop() {
discoveryJob?.cancel()
discoveryJob = null
jobs.forEach { it.cancel() }
jobs.clear()
linkJob?.cancel()
linkJob = null
val open = synchronized(lock) {
val all = admitted.values.mapNotNull { it.connection } +
waiting.values.map { it.second } + listOfNotNull(link)
admitted.clear()
waiting.clear()
link = null
joined = null
refused = false
heardBack = false
replica.reset()
hostSheet = ScoreSheet()
revision = 0
all
}
open.forEach { runCatching { it.close() } }
runCatching { transport.stop() }
_state.value = State()
}
private companion object {
/** A table of friends, not a room. */
const val MAX_GUESTS = 8
const val HELLO_RETRY_MILLIS = 2_000L
const val HELLO_TRIES = 15
const val RECONNECT_ATTEMPTS = 4
const val RECONNECT_BACKOFF_MILLIS = 750L
}
}
@@ -24,6 +24,9 @@ enum class TransportKind(val label: String) {
LAN("Wi-Fi"),
}
/** What a host is hosting, so a phone looking for one sort is not shown the other. */
enum class HostPurpose { GAME, SCOREKEEPER }
/**
* A host someone could join, found by one of the transports. [address] is
* transport-specific: an IP for LAN.
@@ -34,6 +37,7 @@ data class DiscoveredHost(
val kind: TransportKind,
val address: String,
val port: Int = 0,
val purpose: HostPurpose = HostPurpose.GAME,
)
/**
@@ -292,7 +296,11 @@ interface Transport {
fun isAvailable(): Boolean
/** Starts advertising and emits each client that connects. */
fun host(displayName: String, scope: CoroutineScope): Flow<Connection>
fun host(
displayName: String,
scope: CoroutineScope,
purpose: HostPurpose = HostPurpose.GAME,
): Flow<Connection>
/** Emits the current list of visible hosts as it changes. */
fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>>
@@ -0,0 +1,89 @@
package org.prolibertate.games.score
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One change to a score sheet, as something that can be sent.
*
* A shared sheet cannot work by guests posting whole sheets at the host: two
* people entering a round at the same moment would each send a sheet missing the
* other's, and one of the rounds would quietly vanish. Sending what was *done*
* instead means the host can apply everybody's changes in turn, and two rounds
* entered together are simply two rounds.
*
* Players are named by id wherever they are named at all, for the same reason
* [ScorePlayer.id] exists: a position is only right until somebody else moves
* a column. Rounds have no id, so [EditRound] and [DeleteRound] go by index; if
* two people delete rounds in the same instant the second can land on the wrong
* one. Both of them are in the room looking at the same table, and the sheet
* shows what happened.
*
* Applying an op to a sheet it no longer makes sense for — editing a round that
* has since been deleted, renaming a player who has been removed — does nothing,
* as the sheet's own methods already do.
*/
@Serializable
sealed interface SheetOp {
fun applyTo(sheet: ScoreSheet): ScoreSheet
}
/** Sets a fresh sheet up for [count] players. */
@Serializable
@SerialName("start")
data class StartSheet(val count: Int) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count)
}
/** Rubs everything out. */
@Serializable
@SerialName("clear")
data object ClearSheet : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet()
}
@Serializable
@SerialName("add-player")
data object AddPlayer : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withPlayerAdded()
}
@Serializable
@SerialName("remove-player")
data class RemovePlayer(val playerId: Int) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withPlayerRemoved(playerId)
}
@Serializable
@SerialName("rename")
data class RenamePlayer(val playerId: Int, val name: String) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.renamed(playerId, name)
}
/** Moves a player's column so that it sits at position [to]. */
@Serializable
@SerialName("move-player")
data class MovePlayer(val playerId: Int, val to: Int) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet {
val from = sheet.players.indexOfFirst { it.id == playerId }
return if (from < 0) sheet else sheet.moved(from, to)
}
}
@Serializable
@SerialName("add-round")
data class AddRound(val deltas: Map<Int, Int>) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withRound(deltas)
}
@Serializable
@SerialName("edit-round")
data class EditRound(val index: Int, val deltas: Map<Int, Int>) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withRoundAt(index, deltas)
}
@Serializable
@SerialName("delete-round")
data class DeleteRound(val index: Int) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withoutRound(index)
}
@@ -0,0 +1,71 @@
package org.prolibertate.games.score
/**
* A guest's copy of somebody else's score sheet.
*
* The host's sheet is the truth. What a guest *shows* is that sheet with the
* guest's own not-yet-acknowledged changes laid on top, so a number typed into a
* cell appears at once instead of after a round trip — and, more to the point,
* so that a sheet pushed by the host a moment before the guest's change has
* landed does not wipe out what the guest has just done. Each change is tagged
* with a sequence number; the host reports the last one it has applied, and
* everything up to it is dropped from the pile because it is by then part of the
* sheet the host sent.
*
* Not thread-safe. The session that owns one serialises access to it.
*/
class SheetReplica {
private var confirmed = ScoreSheet()
private var nextSeq = 1
private val pending = ArrayDeque<Pair<Int, SheetOp>>()
/** The revision of the host's sheet this was last brought up to. */
var revision: Int = 0
private set
/** What to draw: the host's sheet plus this guest's own unacknowledged changes. */
var view: ScoreSheet = confirmed
private set
/** How many of this guest's changes the host has not yet confirmed. */
val unconfirmed: Int get() = pending.size
/** Records a change made here, and returns the sequence number to send it under. */
fun local(op: SheetOp): Int {
val seq = nextSeq++
pending.addLast(seq to op)
view = op.applyTo(view)
return seq
}
/**
* Takes the host's sheet at [revision], which includes every change of this
* guest's up to and including [acknowledged].
*
* A sheet older than the one already held is ignored; links deliver in
* order, but a resync request and a broadcast can cross.
*/
fun receive(sheet: ScoreSheet, revision: Int, acknowledged: Int) {
if (revision < this.revision) return
this.revision = revision
confirmed = sheet
while (pending.isNotEmpty() && pending.first().first <= acknowledged) pending.removeFirst()
view = pending.fold(confirmed) { acc, (_, op) -> op.applyTo(acc) }
}
/**
* Forgets everything, for a fresh link to the host.
*
* Changes still waiting on the old link are gone with it: nothing says
* whether the host got them, and replaying them could apply them twice. The
* sheet the host sends next is the answer either way.
*/
fun reset() {
confirmed = ScoreSheet()
view = confirmed
pending.clear()
revision = 0
nextSeq = 1
}
}
@@ -43,6 +43,7 @@ import org.prolibertate.games.game.GameMenu
import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.settings.SettingsRepository
@@ -56,6 +57,8 @@ class AppEnv(
val updater: Updater,
val lobby: LobbyController,
val scorekeeper: ScorekeeperRepository,
/** The shared score sheet, for the Scorekeeper to host or join. */
val scoreSession: ScorekeeperSession,
val peerId: String,
)
@@ -186,6 +189,8 @@ private fun AppContent(
is Route.Scorekeeper -> ScorekeeperScreen(
repository = env.scorekeeper,
session = env.scoreSession,
playerName = settings.displayName,
onBack = { pop() },
)
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -30,7 +31,9 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@@ -47,6 +50,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@@ -54,11 +58,25 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.net.ScorekeeperSession.Role
import org.prolibertate.games.score.AddPlayer
import org.prolibertate.games.score.AddRound
import org.prolibertate.games.score.ClearSheet
import org.prolibertate.games.score.DeleteRound
import org.prolibertate.games.score.EditRound
import org.prolibertate.games.score.MovePlayer
import org.prolibertate.games.score.RemovePlayer
import org.prolibertate.games.score.RenamePlayer
import org.prolibertate.games.score.ScorePlayer
import org.prolibertate.games.score.ScoreSheet
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.score.SheetOp
import org.prolibertate.games.score.StartSheet
import kotlin.math.roundToInt
/**
@@ -68,10 +86,18 @@ import kotlin.math.roundToInt
*
* The bottom row is always the one being filled in. Finish it and it is written
* down, and a fresh empty row opens above the totals for the next round.
*
* The sheet can be shared. Sharing makes this device the host of a
* [ScorekeeperSession]; joining makes it a guest of somebody else's. Either way
* every change is a [SheetOp] — applied here when the sheet is only this
* device's, and handed to the session when it is not — so the grid below does
* not need to know which of the three it is drawing.
*/
@Composable
fun ScorekeeperScreen(
repository: ScorekeeperRepository,
session: ScorekeeperSession,
playerName: String,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -80,36 +106,121 @@ fun ScorekeeperScreen(
// of storage on every change would put a DataStore round trip between a
// keystroke and the digit appearing, which is how a field loses characters.
var loaded by remember { mutableStateOf(false) }
var sheet by remember { mutableStateOf(ScoreSheet()) }
var local by remember { mutableStateOf(ScoreSheet()) }
LaunchedEffect(Unit) {
sheet = repository.sheet.first()
local = repository.sheet.first()
loaded = true
}
val update: (ScoreSheet) -> Unit = { next ->
sheet = next
scope.launch { repository.save(next) }
}
val shared by session.state.collectAsState()
val role = shared.role
var joining by remember { mutableStateOf(false) }
var showShare by remember { mutableStateOf(false) }
var confirmNewGame by remember { mutableStateOf(false) }
// A session lives exactly as long as this screen does: leaving it ends the
// sharing, and with it the links.
DisposableEffect(Unit) { onDispose { session.stop() } }
// What the host shares is the host's own sheet, so it is the one kept. A
// phone killed mid-game comes back to the sheet as the guests last saw it.
LaunchedEffect(shared.sheet, role) {
if (role == Role.HOST) repository.save(shared.sheet)
}
// Coming back to the screen after the phone was put down. A guest has missed
// whatever the host did in the meantime, and may have lost its link while the
// radio slept, so it asks again — the same thing a game table does.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) session.sync()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val sheet = if (role == Role.NONE) local else shared.sheet
val change: (SheetOp) -> Unit = { op ->
if (role == Role.NONE) {
val next = op.applyTo(local)
local = next
scope.launch { repository.save(next) }
} else {
session.edit(op)
}
}
// Stops whatever is going on with the network and goes back to this
// device's own sheet — which, for a host, is the one it has been keeping.
val leaveSharing: () -> Unit = {
if (role == Role.HOST) local = shared.sheet
session.stop()
joining = false
showShare = false
}
BackHandler(enabled = role == Role.GUEST || joining) { leaveSharing() }
ScreenScaffold(
title = "Scorekeeper",
onBack = onBack,
title = when (role) {
Role.GUEST -> "Scorekeeper · ${shared.hostName}"
Role.HOST -> "Scorekeeper · sharing"
Role.NONE -> "Scorekeeper"
},
onBack = { if (role == Role.GUEST || joining) leaveSharing() else onBack() },
actions = {
when (role) {
Role.NONE -> if (sheet.started && !joining) {
TextButton(onClick = {
session.startHosting(playerName, local)
showShare = true
}) { Text("Share") }
TextButton(onClick = { confirmNewGame = true }) { Text("New game") }
}
Role.HOST -> {
TextButton(onClick = { showShare = true }) {
Text(if (shared.guests.isEmpty()) "Sharing" else "Sharing (${shared.guests.size})")
}
if (sheet.started) {
TextButton(onClick = { confirmNewGame = true }) { Text("New game") }
}
}
// Rubbing out the sheet is the host's to do; a guest can leave
// and nothing more drastic.
Role.GUEST -> {
TextButton(onClick = { session.sync() }) { Text("Sync") }
TextButton(onClick = leaveSharing) { Text("Leave") }
}
}
},
) { modifier ->
// Nothing is drawn until the stored sheet has been read, so a game in
// progress does not flash the "how many are playing" pane first.
if (!loaded) {
Box(modifier = modifier)
} else if (!sheet.started) {
SetupPane(modifier = modifier) { count -> update(ScoreSheet.of(count)) }
} else {
Sheet(modifier = modifier, sheet = sheet, onChange = update)
when {
!loaded -> Box(modifier = modifier)
role == Role.GUEST && (shared.awaiting || shared.refused || !sheet.started) ->
WaitingPane(modifier = modifier, shared = shared, onLeave = leaveSharing)
role == Role.GUEST -> Column(modifier = modifier) {
ConnectionBanner(shared = shared, onSync = { session.sync() })
Sheet(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
}
joining && role == Role.NONE ->
JoinPane(modifier = modifier, session = session, shared = shared, playerName = playerName)
!sheet.started -> SetupPane(
modifier = modifier,
onStart = { count -> change(StartSheet(count)) },
onJoin = { joining = true },
)
else -> Sheet(modifier = modifier, sheet = sheet, onOp = change)
}
}
@@ -117,11 +228,20 @@ fun ScorekeeperScreen(
AlertDialog(
onDismissRequest = { confirmNewGame = false },
title = { Text("Start a new game?") },
text = { Text("The players and every round scored so far are rubbed out.") },
text = {
Text(
if (role == Role.HOST) {
"The players and every round scored so far are rubbed out, " +
"for everyone on the sheet."
} else {
"The players and every round scored so far are rubbed out."
}
)
},
confirmButton = {
TextButton(onClick = {
confirmNewGame = false
update(ScoreSheet())
change(ClearSheet)
}) { Text("Rub it out") }
},
dismissButton = {
@@ -129,6 +249,25 @@ fun ScorekeeperScreen(
},
)
}
// Asked over whatever else is on screen, one at a time.
if (role == Role.HOST) {
shared.requests.firstOrNull()?.let { request ->
JoinRequestDialog(
request = request,
onAllow = { session.allow(request.id) },
onDeny = { session.deny(request.id) },
)
}
if (showShare) {
ShareDialog(
shared = shared,
onRemove = { session.remove(it) },
onStop = leaveSharing,
onClose = { showShare = false },
)
}
}
}
// ---------------------------------------------------------------------------
@@ -137,7 +276,7 @@ fun ScorekeeperScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) {
private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () -> Unit) {
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
Column(
@@ -170,6 +309,9 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) {
style = MaterialTheme.typography.bodySmall,
)
PrimaryAction(text = "Start scoring") { onStart(count) }
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
Text("Join a shared sheet nearby")
}
}
}
@@ -178,7 +320,7 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) {
// ---------------------------------------------------------------------------
@Composable
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit) {
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
// One scroll state shared by the head, the rows and the totals, so all three
// move together: a column and the name over it must never come apart. Every
// row is built the same way and is therefore exactly the same width, which
@@ -213,7 +355,16 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
// Keep the row being filled in in view when a round is written down.
LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) }
val drag = rememberColumnDrag(sheet = sheet, onChange = onChange)
// On a shared sheet somebody else can delete the round being corrected from
// under it. There is then nothing to correct, and the row it was in is gone.
LaunchedEffect(sheet.rounds.size) {
if (correcting >= sheet.rounds.size) {
correcting = -1
correction.clear()
}
}
val drag = rememberColumnDrag(sheet = sheet, onOp = onOp)
Column(modifier = modifier) {
Text(
@@ -234,7 +385,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
trailing = {
AddColumnCell(
enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS,
onClick = { onChange(sheet.withPlayerAdded()) },
onClick = { onOp(AddPlayer) },
)
},
) { index, player ->
@@ -360,18 +511,18 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
onFlip = { editing[lastTyped] = flipSign(editing[lastTyped].orEmpty()) },
onFinish = {
if (correcting >= 0) {
onChange(sheet.withRoundAt(correcting, amountsAsPoints(correction)))
onOp(EditRound(correcting, amountsAsPoints(correction)))
correcting = -1
correction.clear()
} else {
onChange(sheet.withRound(amountsAsPoints(pending)))
onOp(AddRound(amountsAsPoints(pending)))
pending.clear()
}
lastTyped = -1
focusPlayer = -1
},
onDelete = {
onChange(sheet.withoutRound(correcting))
onOp(DeleteRound(correcting))
correcting = -1
correction.clear()
lastTyped = -1
@@ -391,9 +542,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
RenameDialog(
sheet = sheet,
player = renamed,
onName = { onChange(sheet.renamed(renamed.id, it)) },
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onChange(sheet.withPlayerRemoved(renamed.id))
onOp(RemovePlayer(renamed.id))
renaming = -1
},
onDismiss = { renaming = -1 },
@@ -417,17 +568,17 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
* since.
*/
@Composable
private fun rememberColumnDrag(sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit): ColumnDrag {
private fun rememberColumnDrag(sheet: ScoreSheet, onOp: (SheetOp) -> Unit): ColumnDrag {
val widthPx = with(LocalDensity.current) { PLAYER_COLUMN.toPx() }
val currentSheet by rememberUpdatedState(sheet)
val commit by rememberUpdatedState(onChange)
val commit by rememberUpdatedState(onOp)
return remember { ColumnDrag(widthPx, { currentSheet }, { commit(it) }) }
}
private class ColumnDrag(
private val widthPx: Float,
private val sheet: () -> ScoreSheet,
private val commit: (ScoreSheet) -> Unit,
private val commit: (SheetOp) -> Unit,
) {
/** Which column is under the finger, or -1 when nothing is being dragged. */
var index by mutableStateOf(-1)
@@ -454,7 +605,8 @@ private class ColumnDrag(
val to = landing()
index = -1
offset = 0f
if (from >= 0 && to >= 0) commit(sheet().moved(from, to))
val moved = sheet().players.getOrNull(from)
if (moved != null && to >= 0 && to != from) commit(MovePlayer(moved.id, to))
}
fun cancel() {
@@ -0,0 +1,239 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.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.net.ScorekeeperSession
/*
* The parts of the Scorekeeper that only exist while a sheet is being shared:
* finding one to join, waiting to be let in, and — on the host — letting people
* in and seeing who is there.
*/
/** Guest: the sheets nearby, and a way in by address when they cannot be found. */
@Composable
internal fun JoinPane(
modifier: Modifier,
session: ScorekeeperSession,
shared: ScorekeeperSession.State,
playerName: String,
) {
LaunchedEffect(Unit) { session.startDiscovery() }
var typed by remember { mutableStateOf("") }
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Join a sheet somebody else is keeping. The host is asked to let " +
"you in, and once you are, you see the same sheet they do.",
style = MaterialTheme.typography.bodyMedium,
)
shared.message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
if (shared.discovered.isEmpty()) {
Text("No shared sheets found yet. Make sure the host has tapped Share.")
} else {
shared.discovered.forEach { host ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { session.join(host, playerName) },
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(host.name, fontWeight = FontWeight.Bold)
Text(
text = "over ${host.kind.label}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
Divider()
// Always offered, for the same reason the game lobby always offers it:
// over a phone's hotspot nothing is found, and a list that only says
// "no sheets" leaves nowhere to go.
Text("Join by address", fontWeight = FontWeight.Bold)
Text(
text = "The host's screen shows where it is, under Sharing.",
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 = { session.joinAt(typed, playerName) },
enabled = typed.isNotBlank(),
) { Text("Connect") }
}
}
}
/** Guest: asked to join, and nobody has answered yet — or somebody has said no. */
@Composable
internal fun WaitingPane(
modifier: Modifier,
shared: ScorekeeperSession.State,
onLeave: () -> Unit,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = when {
shared.refused -> shared.message ?: "The host declined."
shared.awaiting -> "Waiting for ${shared.hostName} to let you in…"
else -> "Waiting for ${shared.hostName} to set up the sheet…"
},
style = MaterialTheme.typography.titleMedium,
)
if (shared.awaiting) {
Text(
text = "They will see a request with your name on it.",
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(onClick = onLeave) { Text(if (shared.refused) "Back" else "Cancel") }
}
}
/** Guest: the link is down, or was never up. Shown over a sheet that can still be read. */
@Composable
internal fun ConnectionBanner(shared: ScorekeeperSession.State, onSync: () -> Unit) {
if (shared.connected) return
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = shared.message ?: "Not connected to the host.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onSync) { Text("Reconnect") }
}
}
/**
* Host: somebody is asking to join.
*
* Not dismissible by tapping outside it. A request that goes away on a stray
* touch is a guest told nothing, and a request answered by accident is a stranger
* with a pencil; either way it should take a button.
*/
@Composable
internal fun JoinRequestDialog(
request: ScorekeeperSession.Request,
onAllow: () -> Unit,
onDeny: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("${request.name} wants to join") },
text = { Text("Let them see this sheet and add scores to it?") },
confirmButton = { TextButton(onClick = onAllow) { Text("Allow") } },
dismissButton = { TextButton(onClick = onDeny) { Text("Deny") } },
)
}
/** Host: where this phone is, who is on the sheet, and the way to stop sharing it. */
@Composable
internal fun ShareDialog(
shared: ScorekeeperSession.State,
onRemove: (String) -> Unit,
onStop: () -> Unit,
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
title = { Text("Sharing this sheet") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Others nearby can find it under Scorekeeper → Join a shared sheet.",
style = MaterialTheme.typography.bodySmall,
)
shared.endpoint?.primary?.let { where ->
Text("This phone is at", style = MaterialTheme.typography.labelSmall)
Text(where, fontWeight = FontWeight.Bold)
Text(
text = "If the other phone doesn't find it on its own — which happens " +
"over a phone's hotspot — type that into its Join screen.",
style = MaterialTheme.typography.bodySmall,
)
}
Divider()
Text("On the sheet", fontWeight = FontWeight.Bold)
if (shared.guests.isEmpty()) {
Text("Nobody else yet.", style = MaterialTheme.typography.bodySmall)
}
shared.guests.forEach { guest ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(guest.name)
Text(
text = if (guest.online) "Connected" else "Connection lost",
style = MaterialTheme.typography.bodySmall,
)
}
TextButton(onClick = { onRemove(guest.id) }) { Text("Remove") }
}
}
}
},
confirmButton = { TextButton(onClick = onClose) { Text("Done") } },
dismissButton = { TextButton(onClick = onStop) { Text("Stop sharing") } },
)
}
@@ -0,0 +1,358 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.prolibertate.games.net.ScorekeeperSession.Role
import org.prolibertate.games.score.AddRound
import org.prolibertate.games.score.RenamePlayer
import org.prolibertate.games.score.ScoreSheet
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.atomic.AtomicInteger
/**
* A shared score sheet between real sessions, over real sockets on loopback.
*
* The sheet logic is covered on its own; what is worth testing here is the part
* that only exists with a link in it — being asked before being let in, being
* let back in without asking, and everybody ending up looking at one sheet.
*/
class ScorekeeperSessionTest {
private val scopes = mutableListOf<CoroutineScope>()
private val sessions = mutableListOf<ScorekeeperSession>()
private val transports = mutableListOf<LoopbackTransport>()
@After
fun tearDown() {
sessions.forEach { runCatching { it.stop() } }
transports.forEach { runCatching { it.stop() } }
scopes.forEach { it.cancel() }
}
/** TCP on loopback, with the discovery a test does not need left out. */
private class LoopbackTransport : Transport {
override val kind = TransportKind.LAN
@Volatile
var server: ServerSocket? = null
val port: Int get() = server!!.localPort
override fun isAvailable() = true
override fun host(
displayName: String,
scope: CoroutineScope,
purpose: HostPurpose,
): Flow<Connection> = callbackFlow {
val listening = ServerSocket(0)
server = listening
val accepting = scope.launch(Dispatchers.IO) {
while (isActive && !listening.isClosed) {
val socket = runCatching { listening.accept() }.getOrNull() ?: break
trySend(
StreamConnection(
peerId = "guest:${socket.port}",
kind = TransportKind.LAN,
input = socket.getInputStream(),
output = socket.getOutputStream(),
scope = scope,
onClosed = { runCatching { socket.close() } },
)
)
}
}
awaitClose {
accepting.cancel()
runCatching { listening.close() }
}
}
override fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>> = flowOf(emptyList())
override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection {
val socket = Socket("127.0.0.1", host.port)
return StreamConnection(
peerId = host.id,
kind = TransportKind.LAN,
input = socket.getInputStream(),
output = socket.getOutputStream(),
scope = scope,
onClosed = { runCatching { socket.close() } },
)
}
override fun stop() {
runCatching { server?.close() }
}
}
private fun newSession(deviceId: String): Pair<ScorekeeperSession, LoopbackTransport> {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes += it }
val transport = LoopbackTransport().also { transports += it }
val session = ScorekeeperSession(transport, MutableStateFlow(null), scope, deviceId)
.also { sessions += it }
return session to transport
}
private class Host(val session: ScorekeeperSession, val transport: LoopbackTransport) {
val state get() = session.state.value
}
private fun startHost(sheet: ScoreSheet = ScoreSheet.of(2)): Host {
val (session, transport) = newSession("host-device")
session.startHosting("Hosty", sheet)
await("the host to be listening") { transport.server != null }
return Host(session, transport)
}
private fun joinOf(host: Host) = DiscoveredHost(
id = "lan:127.0.0.1:${host.transport.port}",
name = "Hosty",
kind = TransportKind.LAN,
address = "127.0.0.1",
port = host.transport.port,
purpose = HostPurpose.SCOREKEEPER,
)
private fun guestOf(host: Host, deviceId: String, name: String): ScorekeeperSession {
val (session, _) = newSession(deviceId)
session.join(joinOf(host), name)
return session
}
/** A guest that has asked, been let in, and is looking at the host's sheet. */
private fun admittedGuest(host: Host, deviceId: String, name: String): ScorekeeperSession {
val guest = guestOf(host, deviceId, name)
await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
host.session.allow(deviceId)
await("$name to be let in") { guest.state.value.connected }
return guest
}
@Test
fun `a guest is asked about, and sees nothing, until the host allows it`() {
val host = startHost()
val guest = guestOf(host, "g1", "Gwen")
await("the request to reach the host") { host.state.requests.isNotEmpty() }
assertEquals("Gwen", host.state.requests.single().name)
assertTrue(guest.state.value.awaiting)
assertFalse(guest.state.value.connected)
assertFalse(guest.state.value.sheet.started)
assertTrue(host.state.guests.isEmpty())
host.session.allow("g1")
await("the guest to be let in") { guest.state.value.connected }
assertFalse(guest.state.value.awaiting)
assertEquals(host.state.sheet, guest.state.value.sheet)
assertTrue(guest.state.value.sheet.started)
assertTrue(host.state.requests.isEmpty())
assertEquals(listOf("Gwen"), host.state.guests.map { it.name })
}
@Test
fun `a guest that is turned away stays turned away`() {
val host = startHost()
val guest = guestOf(host, "g1", "Gwen")
await("the request to reach the host") { host.state.requests.isNotEmpty() }
host.session.deny("g1")
await("the refusal to arrive") { guest.state.value.refused }
assertEquals("The host declined.", guest.state.value.message)
assertFalse(guest.state.value.connected)
assertFalse(guest.state.value.sheet.started)
assertTrue(host.state.guests.isEmpty())
assertTrue(host.state.requests.isEmpty())
}
@Test
fun `a guest's change reaches the host and the other guests`() {
val host = startHost()
val first = admittedGuest(host, "g1", "Gwen")
val second = admittedGuest(host, "g2", "Gus")
val player = host.state.sheet.players[0].id
first.edit(AddRound(mapOf(player to 7)))
// Shown at once on the guest that made it, without waiting for the host.
assertEquals(7, first.state.value.sheet.total(player))
await("the host to apply it") { host.state.sheet.total(player) == 7 }
await("the other guest to see it") { second.state.value.sheet.total(player) == 7 }
assertEquals(1, host.state.sheet.rounds.size)
}
@Test
fun `the host's own change reaches the guests`() {
val host = startHost()
val guest = admittedGuest(host, "g1", "Gwen")
val player = host.state.sheet.players[1].id
host.session.edit(RenamePlayer(player, "Hosty"))
await("the guest to see the new name") {
guest.state.value.sheet.players[1].name == "Hosty"
}
}
@Test
fun `changes made at the same moment all land`() {
val host = startHost()
val first = admittedGuest(host, "g1", "Gwen")
val second = admittedGuest(host, "g2", "Gus")
val player = host.state.sheet.players[0].id
first.edit(AddRound(mapOf(player to 1)))
second.edit(AddRound(mapOf(player to 10)))
host.session.edit(AddRound(mapOf(player to 100)))
await("every round to land everywhere") {
listOf(host.state.sheet, first.state.value.sheet, second.state.value.sheet)
.all { it.rounds.size == 3 && it.total(player) == 111 }
}
}
@Test
fun `a guest asking to sync is sent the sheet as it stands`() {
val host = startHost()
val guest = admittedGuest(host, "g1", "Gwen")
val player = host.state.sheet.players[0].id
host.session.edit(AddRound(mapOf(player to 4)))
guest.sync()
await("the guest to be in step") { guest.state.value.sheet.total(player) == 4 }
assertEquals(host.state.sheet, guest.state.value.sheet)
}
@Test
fun `a guest that was let in is let back in without being asked again`() {
val host = startHost()
val first = admittedGuest(host, "g1", "Gwen")
// The phone is put down and its link goes with it.
first.stop()
await("the host to notice") { host.state.guests.singleOrNull()?.online == false }
val back = guestOf(host, "g1", "Gwen")
await("the guest to be back on the sheet") { back.state.value.connected }
assertTrue(host.state.requests.isEmpty())
assertEquals(1, host.state.guests.size)
assertTrue(host.state.guests.single().online)
assertEquals(host.state.sheet, back.state.value.sheet)
}
@Test
fun `a guest the host removes has to ask again`() {
val host = startHost()
val guest = admittedGuest(host, "g1", "Gwen")
host.session.remove("g1")
await("the removal to arrive") { guest.state.value.refused }
assertEquals("The host removed you.", guest.state.value.message)
assertTrue(host.state.guests.isEmpty())
val again = guestOf(host, "g1", "Gwen")
await("the new request") { host.state.requests.any { it.id == "g1" } }
assertFalse(again.state.value.connected)
}
@Test
fun `somebody who is not let in cannot change the sheet`() {
val host = startHost()
val (_, transport) = newSession("stranger")
val before = host.state.sheet
val player = before.players[0].id
runBlocking {
val raw = transport.join(joinOf(host), CoroutineScope(Dispatchers.IO).also { scopes += it })
val hello = Hello("stranger", "Stan", purpose = PURPOSE_SCOREKEEPER)
// Said more than once, as a real guest does: the host may not be
// listening yet the first time. Saying it twice is harmless.
repeat(3) {
raw.send(hello)
delay(150)
}
raw.send(SheetEdit(1, AddRound(mapOf(player to 99))))
}
await("the request to reach the host") { host.state.requests.any { it.id == "stranger" } }
Thread.sleep(300)
assertEquals(before, host.state.sheet)
}
@Test
fun `a phone looking for a game is told this is a score sheet`() {
val host = startHost()
val (_, transport) = newSession("gamer")
val answer = AtomicInteger(0)
runBlocking {
val scope = CoroutineScope(Dispatchers.IO).also { scopes += it }
val raw = transport.join(joinOf(host), scope)
val reply = scope.launch {
raw.incoming
.onSubscription { raw.send(Hello("gamer", "Gail", purpose = PURPOSE_GAME)) }
.collect { message ->
if (message is Welcome && !message.accepted) {
assertEquals("That is a score sheet, not a game.", message.reason)
answer.set(1)
}
}
}
val hello = Hello("gamer", "Gail", purpose = PURPOSE_GAME)
val end = System.currentTimeMillis() + 5_000
while (answer.get() == 0 && System.currentTimeMillis() < end) {
delay(300)
if (answer.get() == 0) raw.send(hello)
}
reply.cancel()
}
assertEquals(1, answer.get())
assertTrue(host.state.requests.isEmpty())
assertEquals(Role.HOST, host.state.role)
}
@Test
fun `stopping hosting lets go of everybody`() {
val host = startHost()
val guest = admittedGuest(host, "g1", "Gwen")
host.session.stop()
assertEquals(Role.NONE, host.state.role)
assertTrue(host.state.guests.isEmpty())
await("the guest to notice") { !guest.state.value.connected }
}
private fun await(what: String, timeoutMillis: Long = 5_000, condition: () -> Boolean) {
val end = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() < end) {
if (condition()) return
Thread.sleep(25)
}
fail("Timed out waiting for $what")
}
}
@@ -0,0 +1,176 @@
package org.prolibertate.games.score
import kotlinx.serialization.encodeToString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.net.Hello
import org.prolibertate.games.net.NetMessage
import org.prolibertate.games.net.PURPOSE_GAME
import org.prolibertate.games.net.SheetEdit
import org.prolibertate.games.net.SheetState
import org.prolibertate.games.net.protocolJson
class SheetOpTest {
private val table = ScoreSheet.of(3)
private val ids = table.players.map { it.id }
@Test
fun `every op does what the sheet method of the same name does`() {
assertEquals(table.withPlayerAdded(), AddPlayer.applyTo(table))
assertEquals(table.renamed(ids[1], "Bob"), RenamePlayer(ids[1], "Bob").applyTo(table))
assertEquals(table.withPlayerRemoved(ids[2]), RemovePlayer(ids[2]).applyTo(table))
assertEquals(table.moved(0, 2), MovePlayer(ids[0], 2).applyTo(table))
assertEquals(ScoreSheet.of(5), StartSheet(5).applyTo(table))
assertEquals(ScoreSheet(), ClearSheet.applyTo(table))
val scored = AddRound(mapOf(ids[0] to 4)).applyTo(table)
assertEquals(table.withRound(mapOf(ids[0] to 4)), scored)
assertEquals(4, EditRound(0, mapOf(ids[0] to 4)).applyTo(AddRound(mapOf(ids[0] to 1)).applyTo(table)).total(ids[0]))
assertTrue(DeleteRound(0).applyTo(scored).rounds.isEmpty())
}
@Test
fun `an op that no longer makes sense is a no-op`() {
val scored = AddRound(mapOf(ids[0] to 4)).applyTo(table)
assertEquals(scored, RenamePlayer(999, "Nobody").applyTo(scored))
assertEquals(scored, MovePlayer(999, 0).applyTo(scored))
assertEquals(scored, RemovePlayer(999).applyTo(scored))
assertEquals(scored, EditRound(7, mapOf(ids[0] to 1)).applyTo(scored))
assertEquals(scored, DeleteRound(7).applyTo(scored))
}
@Test
fun `moving a column goes by who it is, not where it was`() {
// Somebody else has already moved the column this guest was looking at.
val shuffled = table.moved(0, 2)
val moved = MovePlayer(ids[0], 0).applyTo(shuffled)
assertEquals(ids, moved.players.map { it.id })
}
@Test
fun `two rounds entered at once are two rounds`() {
val both = listOf(
AddRound(mapOf(ids[0] to 3)),
AddRound(mapOf(ids[1] to 5)),
).fold(table) { sheet, op -> op.applyTo(sheet) }
assertEquals(2, both.rounds.size)
assertEquals(3, both.total(ids[0]))
assertEquals(5, both.total(ids[1]))
}
@Test
fun `every op survives the wire`() {
val ops = listOf(
StartSheet(4), ClearSheet, AddPlayer, RemovePlayer(2), RenamePlayer(1, "Ann"),
MovePlayer(1, 3), AddRound(mapOf(1 to 5, 2 to -3, 3 to 0)),
EditRound(0, mapOf(1 to 9)), DeleteRound(2),
)
ops.forEach { op ->
val message: NetMessage = SheetEdit(seq = 7, op = op)
val decoded = protocolJson.decodeFromString<NetMessage>(protocolJson.encodeToString(message))
assertEquals(message, decoded)
}
}
@Test
fun `a sheet survives the wire with its rounds`() {
val sheet = AddRound(mapOf(ids[0] to 3, ids[2] to -2)).applyTo(table)
val message: NetMessage = SheetState(revision = 4, sheet = sheet, acknowledged = 2)
assertEquals(message, protocolJson.decodeFromString<NetMessage>(protocolJson.encodeToString(message)))
}
@Test
fun `a hello from before there were score sheets is a hello for a game`() {
val old = """{"t":"hello","peerId":"p","displayName":"Ann","protocol":2}"""
val hello = protocolJson.decodeFromString<NetMessage>(old) as Hello
assertEquals(PURPOSE_GAME, hello.purpose)
}
}
class SheetReplicaTest {
private val base = ScoreSheet.of(2)
private val a = base.players[0].id
private val b = base.players[1].id
@Test
fun `a change shows at once`() {
val replica = SheetReplica()
replica.receive(base, revision = 1, acknowledged = 0)
replica.local(AddRound(mapOf(a to 5)))
assertEquals(5, replica.view.total(a))
assertEquals(1, replica.unconfirmed)
}
@Test
fun `a sheet that has not seen my change does not wipe it out`() {
val replica = SheetReplica()
replica.receive(base, 1, 0)
replica.local(AddRound(mapOf(a to 5)))
// The host pushes a rename it made before it had heard about the round.
replica.receive(base.renamed(b, "Bob"), 2, 0)
assertEquals("Bob", replica.view.players[1].name)
assertEquals(5, replica.view.total(a))
}
@Test
fun `a change the host has applied is not applied again`() {
val replica = SheetReplica()
replica.receive(base, 1, 0)
val seq = replica.local(AddRound(mapOf(a to 5)))
// The host's sheet now includes the round, and says so.
replica.receive(base.withRound(mapOf(a to 5)), 2, seq)
assertEquals(5, replica.view.total(a))
assertEquals(1, replica.view.rounds.size)
assertEquals(0, replica.unconfirmed)
}
@Test
fun `only the acknowledged changes are dropped`() {
val replica = SheetReplica()
replica.receive(base, 1, 0)
val first = replica.local(AddRound(mapOf(a to 1)))
replica.local(AddRound(mapOf(a to 10)))
replica.receive(base.withRound(mapOf(a to 1)), 2, first)
assertEquals(1, replica.unconfirmed)
assertEquals(11, replica.view.total(a))
assertEquals(2, replica.view.rounds.size)
}
@Test
fun `an older sheet that arrives late is ignored`() {
val replica = SheetReplica()
replica.receive(base.withRound(mapOf(a to 3)), 5, 0)
replica.receive(base, 4, 0)
assertEquals(3, replica.view.total(a))
assertEquals(5, replica.revision)
}
@Test
fun `a fresh link starts from nothing`() {
val replica = SheetReplica()
replica.receive(base, 3, 0)
replica.local(AddRound(mapOf(a to 5)))
replica.reset()
assertEquals(ScoreSheet(), replica.view)
assertEquals(0, replica.unconfirmed)
// And a sheet at an early revision is taken rather than thought stale.
replica.receive(base, 1, 0)
assertEquals(base, replica.view)
}
}