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:
Claude
2026-08-06 21:58:01 +00:00
12 changed files with 29 additions and 260 deletions
+3 -9
View File
@@ -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
+1 -1
View File
@@ -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")
+2 -21
View File
@@ -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.
*/ */