Merge dev: remove Bluetooth from multiplayer
Multiplayer is now Wi-Fi (LAN) only. The BluetoothTransport, its RFCOMM plumbing, the BLUETOOTH/BLUETOOTH_ADMIN/BLUETOOTH_CONNECT permissions and the runtime consent prompt are all gone; the transport abstraction stays, so LanTransport is simply the only implementation of it now. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YEsqYAMJKUJoM7UAavm169
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Pro Libertate Games
|
# Pro Libertate Games
|
||||||
|
|
||||||
A collection of card and board games for Android, playable against the computer
|
A collection of card and board games for Android, playable against the computer
|
||||||
or against other people over Wi-Fi (LAN) or Bluetooth.
|
or against other people over Wi-Fi (LAN).
|
||||||
|
|
||||||
## What works today
|
## What works today
|
||||||
|
|
||||||
@@ -24,19 +24,13 @@ layers are game-agnostic and need no changes.
|
|||||||
## Playing with other people
|
## Playing with other people
|
||||||
|
|
||||||
Pick a game, choose **Host a game for others to join**, and the device advertises
|
Pick a game, choose **Host a game for others to join**, and the device advertises
|
||||||
itself on every radio it has. Other devices choose **Join a game nearby** and
|
itself on the network. Other devices choose **Join a game nearby** and
|
||||||
pick the host from the list. Seats start out filled by the computer and are
|
pick the host from the list. Seats start out filled by the computer and are
|
||||||
handed to people as they join, so any mix of humans and AI works — whatever is
|
handed to people as they join, so any mix of humans and AI works — whatever is
|
||||||
still marked *Computer* when the host starts stays AI.
|
still marked *Computer* when the host starts stays AI.
|
||||||
|
|
||||||
- **Wi-Fi (LAN)** — both devices must be on the same network. Discovery uses
|
- **Wi-Fi (LAN)** — both devices must be on the same network. Discovery uses
|
||||||
mDNS/NSD; play runs over a TCP socket.
|
mDNS/NSD; play runs over a TCP socket.
|
||||||
- **Bluetooth** — devices must already be **paired** in Android's Bluetooth
|
|
||||||
settings. Play runs over Classic RFCOMM. Scanning for unpaired devices is not
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
Both transports can be live at once, so some players can be on Wi-Fi and others
|
|
||||||
on Bluetooth at the same table.
|
|
||||||
|
|
||||||
### How a match is kept in step
|
### How a match is kept in step
|
||||||
|
|
||||||
@@ -207,7 +201,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
|||||||
crazyeights/ Crazy 8s model, rules, AI
|
crazyeights/ Crazy 8s model, rules, AI
|
||||||
chess/ Chess model, FEN, move generation, search
|
chess/ Chess model, FEN, move generation, search
|
||||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||||
net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
|
net/ wire protocol, LAN transport, lobby, match driver
|
||||||
settings/ DataStore-backed preferences
|
settings/ DataStore-backed preferences
|
||||||
update/ GitHub Releases OTA updater
|
update/ GitHub Releases OTA updater
|
||||||
ui/ Compose screens
|
ui/ Compose screens
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ dependencies {
|
|||||||
// Settings persistence for sound / animation speed / update preferences.
|
// Settings persistence for sound / animation speed / update preferences.
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
|
|
||||||
// Wire format for the LAN and Bluetooth game protocol.
|
// Wire format for the LAN game protocol.
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
|
|
||||||
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. -->
|
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
@@ -10,25 +9,7 @@
|
|||||||
<!-- Lets the app hand a downloaded APK to the system installer. -->
|
<!-- Lets the app hand a downloaded APK to the system installer. -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<!-- Bluetooth. Android 12 (API 31) replaced the catch-all BLUETOOTH
|
<!-- The radio is optional: the app is fully playable against AI without it. -->
|
||||||
permission with explicit ones, so the legacy pair is capped below 31
|
|
||||||
and BLUETOOTH_CONNECT is requested at runtime above it.
|
|
||||||
|
|
||||||
Only CONNECT is declared because the transport talks exclusively to
|
|
||||||
devices that are already paired: it enumerates bonded devices and
|
|
||||||
opens an RFCOMM socket, neither of which is a scan. Adding discovery
|
|
||||||
of unpaired devices later would mean declaring BLUETOOTH_SCAN here
|
|
||||||
(and, below API 31, both ACCESS_FINE_LOCATION and
|
|
||||||
ACCESS_COARSE_LOCATION, which Android requires together). -->
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
|
||||||
tools:targetApi="s" />
|
|
||||||
|
|
||||||
<!-- Both radios are optional: the app is fully playable against AI without them. -->
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
|
||||||
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package org.prolibertate.games
|
package org.prolibertate.games
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings as AndroidSettings
|
import android.provider.Settings as AndroidSettings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
@@ -20,14 +17,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private lateinit var env: AppEnv
|
private lateinit var env: AppEnv
|
||||||
|
|
||||||
/**
|
|
||||||
* Bluetooth needs runtime consent from Android 12 onwards. The result is
|
|
||||||
* ignored deliberately: refusing simply means the Bluetooth transport
|
|
||||||
* reports itself unavailable and the lobby falls back to Wi-Fi.
|
|
||||||
*/
|
|
||||||
private val bluetoothPermissions =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -36,7 +25,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
updater = Updater(this),
|
updater = Updater(this),
|
||||||
lobby = LobbyController(applicationContext, lifecycleScope),
|
lobby = LobbyController(applicationContext, lifecycleScope),
|
||||||
peerId = stablePeerId(),
|
peerId = stablePeerId(),
|
||||||
requestBluetoothPermissions = ::requestBluetoothPermissions,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
@@ -46,14 +34,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestBluetoothPermissions() {
|
|
||||||
// Only CONNECT: the transport reaches paired devices and never scans.
|
|
||||||
// See the manifest for what discovery of unpaired devices would add.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
bluetoothPermissions.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identifies this device to a host for the length of an install. ANDROID_ID
|
* Identifies this device to a host for the length of an install. ANDROID_ID
|
||||||
* is per-app-signing-key and survives restarts, which is all a lobby needs
|
* is per-app-signing-key and survives restarts, which is all a lobby needs
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ enum class PlayerKind {
|
|||||||
/** A person on this device. */
|
/** A person on this device. */
|
||||||
HUMAN_LOCAL,
|
HUMAN_LOCAL,
|
||||||
|
|
||||||
/** A person on another device, reached over LAN or Bluetooth. */
|
/** A person on another device, reached over the LAN. */
|
||||||
HUMAN_REMOTE,
|
HUMAN_REMOTE,
|
||||||
|
|
||||||
/** Driven by the host's AI. */
|
/** Driven by the host's AI. */
|
||||||
@@ -28,7 +28,7 @@ data class PlayerSlot(
|
|||||||
*
|
*
|
||||||
* Kept alongside [peerId] because the two answer different questions.
|
* Kept alongside [peerId] because the two answer different questions.
|
||||||
* [peerId] is how the host recognises which link a message came in on, and is
|
* [peerId] is how the host recognises which link a message came in on, and is
|
||||||
* an address the transport made up — a socket's IP, a Bluetooth MAC. This is
|
* an address the transport made up — a socket's IP. This is
|
||||||
* how a guest recognises *itself* in a seat list it did not write, and only
|
* how a guest recognises *itself* in a seat list it did not write, and only
|
||||||
* the guest's own id can do that.
|
* the guest's own id can do that.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
package org.prolibertate.games.net
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.BluetoothServerSocket
|
|
||||||
import android.bluetooth.BluetoothSocket
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bluetooth play over Classic RFCOMM.
|
|
||||||
*
|
|
||||||
* RFCOMM rather than BLE: this is a sustained, ordered, two-way stream of small
|
|
||||||
* messages, which is exactly what a serial profile is for. BLE would mean
|
|
||||||
* chunking everything into 20-byte GATT writes for no benefit.
|
|
||||||
*
|
|
||||||
* Discovery is limited to *paired* devices. Pair the two devices once in
|
|
||||||
* Android's Bluetooth settings and they will see each other here; scanning for
|
|
||||||
* unpaired devices needs a foreground scan plus location permission on older
|
|
||||||
* releases and is not worth the friction for a game lobby.
|
|
||||||
*/
|
|
||||||
class BluetoothTransport(private val context: Context) : Transport {
|
|
||||||
|
|
||||||
override val kind: TransportKind = TransportKind.BLUETOOTH
|
|
||||||
|
|
||||||
private val serviceUuid: UUID = UUID.fromString(RFCOMM_UUID)
|
|
||||||
|
|
||||||
private val adapter: BluetoothAdapter?
|
|
||||||
get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
|
||||||
|
|
||||||
private var serverSocket: BluetoothServerSocket? = null
|
|
||||||
|
|
||||||
override fun isAvailable(): Boolean = adapter?.isEnabled == true && hasConnectPermission()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android 12 replaced the blanket BLUETOOTH permission with BLUETOOTH_CONNECT.
|
|
||||||
* Below 31 the manifest permission is granted at install time.
|
|
||||||
*/
|
|
||||||
fun hasConnectPermission(): Boolean =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
android.Manifest.permission.BLUETOOTH_CONNECT,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Hosting
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
|
|
||||||
val bluetooth = adapter
|
|
||||||
if (bluetooth == null || !hasConnectPermission()) {
|
|
||||||
close()
|
|
||||||
return@callbackFlow
|
|
||||||
}
|
|
||||||
|
|
||||||
val server = runCatching {
|
|
||||||
bluetooth.listenUsingRfcommWithServiceRecord(SERVICE_NAME, serviceUuid)
|
|
||||||
}.getOrNull()
|
|
||||||
if (server == null) {
|
|
||||||
close()
|
|
||||||
return@callbackFlow
|
|
||||||
}
|
|
||||||
serverSocket = server
|
|
||||||
|
|
||||||
val acceptJob = scope.launch(Dispatchers.IO) {
|
|
||||||
while (isActive) {
|
|
||||||
val socket: BluetoothSocket = runCatching { server.accept() }.getOrNull() ?: break
|
|
||||||
val connection = StreamConnection(
|
|
||||||
peerId = runCatching { socket.remoteDevice?.address }.getOrNull() ?: "bt",
|
|
||||||
kind = TransportKind.BLUETOOTH,
|
|
||||||
input = socket.inputStream,
|
|
||||||
output = socket.outputStream,
|
|
||||||
scope = scope,
|
|
||||||
onClosed = { runCatching { socket.close() } },
|
|
||||||
)
|
|
||||||
trySend(connection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
awaitClose {
|
|
||||||
acceptJob.cancel()
|
|
||||||
runCatching { server.close() }
|
|
||||||
serverSocket = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Discovery
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paired devices are re-published on a slow poll: there is no callback for
|
|
||||||
* "a device became reachable", and a host that is not listening simply
|
|
||||||
* refuses the connection when joined.
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>> = callbackFlow {
|
|
||||||
if (!hasConnectPermission()) {
|
|
||||||
trySend(emptyList())
|
|
||||||
close()
|
|
||||||
return@callbackFlow
|
|
||||||
}
|
|
||||||
|
|
||||||
val pollJob = scope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
val bonded = runCatching { adapter?.bondedDevices.orEmpty() }.getOrDefault(emptySet())
|
|
||||||
trySend(
|
|
||||||
bonded.map { device ->
|
|
||||||
DiscoveredHost(
|
|
||||||
id = "bt:${device.address}",
|
|
||||||
name = runCatching { device.name }.getOrNull() ?: device.address,
|
|
||||||
kind = TransportKind.BLUETOOTH,
|
|
||||||
address = device.address,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
delay(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
awaitClose { pollJob.cancel() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Joining
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val bluetooth = requireNotNull(adapter) { "No Bluetooth adapter" }
|
|
||||||
require(hasConnectPermission()) { "Bluetooth permission not granted" }
|
|
||||||
|
|
||||||
val device = bluetooth.getRemoteDevice(host.address)
|
|
||||||
// Discovery is expensive and slows down a connect attempt sharply.
|
|
||||||
runCatching { bluetooth.cancelDiscovery() }
|
|
||||||
|
|
||||||
val socket = device.createRfcommSocketToServiceRecord(serviceUuid)
|
|
||||||
socket.connect()
|
|
||||||
StreamConnection(
|
|
||||||
peerId = host.id,
|
|
||||||
kind = TransportKind.BLUETOOTH,
|
|
||||||
input = socket.inputStream,
|
|
||||||
output = socket.outputStream,
|
|
||||||
scope = scope,
|
|
||||||
onClosed = { runCatching { socket.close() } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
runCatching { serverSocket?.close() }
|
|
||||||
serverSocket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val POLL_INTERVAL_MS = 4_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,12 +14,11 @@ import org.prolibertate.games.game.engine.TableConfig
|
|||||||
/**
|
/**
|
||||||
* The pre-game lobby.
|
* The pre-game lobby.
|
||||||
*
|
*
|
||||||
* The host advertises on every available transport at once, so a table can mix
|
* The host advertises on every available transport at once — from the seat
|
||||||
* players arriving over Wi-Fi with players arriving over Bluetooth — from the
|
* list's point of view a peer is a peer, whichever one it arrived on. Seats
|
||||||
* seat list's point of view a peer is a peer. Seats start filled with AI and
|
* start filled with AI and are handed over to people as they join, which is
|
||||||
* are handed over to people as they join, which is what makes "AI, humans, or
|
* what makes "AI, humans, or any combination" fall out for free: whatever is
|
||||||
* any combination" fall out for free: whatever is still AI when the host
|
* still AI when the host starts, stays AI.
|
||||||
* starts, stays AI.
|
|
||||||
*/
|
*/
|
||||||
class LobbyController(
|
class LobbyController(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -40,9 +39,8 @@ class LobbyController(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val lan = LanTransport(context)
|
private val lan = LanTransport(context)
|
||||||
private val bluetooth = BluetoothTransport(context)
|
|
||||||
|
|
||||||
val transports: List<Transport> = listOf(lan, bluetooth)
|
val transports: List<Transport> = listOf(lan)
|
||||||
|
|
||||||
private val _state = MutableStateFlow(State())
|
private val _state = MutableStateFlow(State())
|
||||||
val state: StateFlow<State> = _state.asStateFlow()
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.prolibertate.games.game.engine.PlayerSlot
|
|||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The wire protocol, shared by the LAN and Bluetooth transports.
|
* The wire protocol, shared by every transport.
|
||||||
*
|
*
|
||||||
* The host is authoritative: clients send [MoveIntent] and render whatever
|
* The host is authoritative: clients send [MoveIntent] and render whatever
|
||||||
* [StateSync] tells them. Nothing is trusted from a client beyond "I would like
|
* [StateSync] tells them. Nothing is trusted from a client beyond "I would like
|
||||||
@@ -18,13 +18,10 @@ import org.prolibertate.games.game.engine.TableConfig
|
|||||||
*/
|
*/
|
||||||
const val PROTOCOL_VERSION = 1
|
const val PROTOCOL_VERSION = 1
|
||||||
|
|
||||||
/** Service type advertised over mDNS, and the RFCOMM service name. */
|
/** Service type and name advertised over mDNS. */
|
||||||
const val SERVICE_TYPE = "_plgames._tcp"
|
const val SERVICE_TYPE = "_plgames._tcp"
|
||||||
const val SERVICE_NAME = "ProLibertateGames"
|
const val SERVICE_NAME = "ProLibertateGames"
|
||||||
|
|
||||||
/** Fixed RFCOMM UUID. Both ends must agree; this is ours. */
|
|
||||||
const val RFCOMM_UUID = "8f1d5a20-3a4e-4b26-9d31-6c0d2f7a91b4"
|
|
||||||
|
|
||||||
val protocolJson: Json = Json {
|
val protocolJson: Json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
|
|||||||
@@ -19,12 +19,11 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
|
|
||||||
enum class TransportKind(val label: String) {
|
enum class TransportKind(val label: String) {
|
||||||
LAN("Wi-Fi"),
|
LAN("Wi-Fi"),
|
||||||
BLUETOOTH("Bluetooth"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A host someone could join, found by one of the transports. [address] is
|
* A host someone could join, found by one of the transports. [address] is
|
||||||
* transport-specific: an IP for LAN, a MAC for Bluetooth.
|
* transport-specific: an IP for LAN.
|
||||||
*/
|
*/
|
||||||
data class DiscoveredHost(
|
data class DiscoveredHost(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -37,8 +36,8 @@ data class DiscoveredHost(
|
|||||||
/**
|
/**
|
||||||
* One peer-to-peer link carrying [NetMessage]s.
|
* One peer-to-peer link carrying [NetMessage]s.
|
||||||
*
|
*
|
||||||
* Both transports end up with a plain pair of streams, so the framing and
|
* A transport ends up with a plain pair of streams, so the framing and
|
||||||
* serialisation live here once rather than twice.
|
* serialisation live here rather than in each transport.
|
||||||
*/
|
*/
|
||||||
interface Connection : Closeable {
|
interface Connection : Closeable {
|
||||||
val peerId: String
|
val peerId: String
|
||||||
@@ -152,9 +151,8 @@ class StreamConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discovery and connection setup. Everything after the handshake is the same
|
* Discovery and connection setup. Everything after the handshake is transport
|
||||||
* for both transports, which is what lets a lobby accept players over Wi-Fi and
|
* independent, so a lobby accepts players without caring how they arrived.
|
||||||
* Bluetooth at the same time.
|
|
||||||
*/
|
*/
|
||||||
interface Transport {
|
interface Transport {
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ class AppEnv(
|
|||||||
val updater: Updater,
|
val updater: Updater,
|
||||||
val lobby: LobbyController,
|
val lobby: LobbyController,
|
||||||
val peerId: String,
|
val peerId: String,
|
||||||
val requestBluetoothPermissions: () -> Unit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import org.prolibertate.games.net.seatForDevice
|
|||||||
/**
|
/**
|
||||||
* Hosting or joining a table.
|
* Hosting or joining a table.
|
||||||
*
|
*
|
||||||
* A host advertises on Wi-Fi and Bluetooth simultaneously, and the seat list
|
* A host advertises on every available transport, and the seat list does not
|
||||||
* does not care which one a given player arrived on.
|
* care which one a given player arrived on.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LobbyScreen(
|
fun LobbyScreen(
|
||||||
@@ -44,7 +44,6 @@ fun LobbyScreen(
|
|||||||
val state by env.lobby.state.collectAsState()
|
val state by env.lobby.state.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(route) {
|
LaunchedEffect(route) {
|
||||||
env.requestBluetoothPermissions()
|
|
||||||
if (route.hosting) {
|
if (route.hosting) {
|
||||||
// Seat the table the host actually set up, not the catalogue
|
// Seat the table the host actually set up, not the catalogue
|
||||||
// minimum: the rules engine rejects a table of the wrong size.
|
// minimum: the rules engine rejects a table of the wrong size.
|
||||||
@@ -80,7 +79,7 @@ fun LobbyScreen(
|
|||||||
val available = env.lobby.availableTransports()
|
val available = env.lobby.availableTransports()
|
||||||
Text(
|
Text(
|
||||||
text = if (available.isEmpty()) {
|
text = if (available.isEmpty()) {
|
||||||
"No Wi-Fi or Bluetooth available. Turn one on to play with others."
|
"No Wi-Fi available. Turn it on to play with others."
|
||||||
} else {
|
} else {
|
||||||
(if (route.hosting) "Visible over " else "Searching over ") +
|
(if (route.hosting) "Visible over " else "Searching over ") +
|
||||||
available.joinToString(" and ") { it.kind.label } + "."
|
available.joinToString(" and ") { it.kind.label } + "."
|
||||||
@@ -157,8 +156,7 @@ private fun JoiningBody(
|
|||||||
if (discovered.isEmpty()) {
|
if (discovered.isEmpty()) {
|
||||||
Text("No games found yet. Make sure the host has started theirs.")
|
Text("No games found yet. Make sure the host has started theirs.")
|
||||||
Text(
|
Text(
|
||||||
text = "Bluetooth only shows devices already paired in Android's " +
|
text = "Both devices need to be on the same Wi-Fi network.",
|
||||||
"Bluetooth settings.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ class SeatForDeviceTest {
|
|||||||
* The regression this exists for.
|
* The regression this exists for.
|
||||||
*
|
*
|
||||||
* The seat used to be looked up by [PlayerSlot.peerId], which is the address
|
* The seat used to be looked up by [PlayerSlot.peerId], which is the address
|
||||||
* the host's own transport made up for the incoming link — an IP on Wi-Fi, a
|
* the host's own transport made up for the incoming link — an IP on Wi-Fi. A
|
||||||
* MAC over Bluetooth. A guest has never seen that value and so never matched
|
* guest has never seen that value and so never matched it, and every guest
|
||||||
* it, and every guest fell through to "the first seat held by somebody
|
* fell through to "the first seat held by somebody remote". With one guest
|
||||||
* remote". With one guest that is right by luck. With two, both of them
|
* that is right by luck. With two, both of them
|
||||||
* claimed seat 1, and the one really sitting in seat 2 was left looking at a
|
* claimed seat 1, and the one really sitting in seat 2 was left looking at a
|
||||||
* table with no cards in front of it.
|
* table with no cards in front of it.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user