Remove Bluetooth from multiplayer

Multiplayer now runs over Wi-Fi (LAN) only.

- Delete BluetoothTransport and drop it from LobbyController's transport
  list, leaving LanTransport as the sole transport.
- Drop TransportKind.BLUETOOTH and the now-unused RFCOMM_UUID constant.
- Remove the BLUETOOTH/BLUETOOTH_ADMIN/BLUETOOTH_CONNECT permissions and
  the android.hardware.bluetooth feature from the manifest, along with
  the runtime permission request in MainActivity and the
  requestBluetoothPermissions hook on AppEnv.
- Update lobby copy so an empty transport list and an empty host list
  talk about Wi-Fi rather than pairing, and refresh the comments and
  README sections that described two transports.

The transport abstraction itself is kept: everything after the handshake
was already transport independent, so adding another one later needs no
changes above LanTransport.

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:38:35 +00:00
parent e17272b909
commit 43394b8d45
12 changed files with 29 additions and 260 deletions
+2 -21
View File
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. -->
<uses-permission android:name="android.permission.INTERNET" />
@@ -10,25 +9,7 @@
<!-- Lets the app hand a downloaded APK to the system installer. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Bluetooth. Android 12 (API 31) replaced the catch-all BLUETOOTH
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" />
<!-- The radio is optional: the app is fully playable against AI without it. -->
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<application
@@ -1,12 +1,9 @@
package org.prolibertate.games
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.settings.SettingsRepository
@@ -20,14 +17,6 @@ class MainActivity : ComponentActivity() {
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?) {
super.onCreate(savedInstanceState)
@@ -36,7 +25,6 @@ class MainActivity : ComponentActivity() {
updater = Updater(this),
lobby = LobbyController(applicationContext, lifecycleScope),
peerId = stablePeerId(),
requestBluetoothPermissions = ::requestBluetoothPermissions,
)
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
* 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. */
HUMAN_LOCAL,
/** A person on another device, reached over LAN or Bluetooth. */
/** A person on another device, reached over the LAN. */
HUMAN_REMOTE,
/** Driven by the host's AI. */
@@ -28,7 +28,7 @@ data class PlayerSlot(
*
* Kept alongside [peerId] because the two answer different questions.
* [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
* 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 host advertises on every available transport at once, so a table can mix
* players arriving over Wi-Fi with players arriving over Bluetooth — from the
* seat list's point of view a peer is a peer. Seats start filled with AI and
* are handed over to people as they join, which is what makes "AI, humans, or
* any combination" fall out for free: whatever is still AI when the host
* starts, stays AI.
* The host advertises on every available transport at once — from the seat
* list's point of view a peer is a peer, whichever one it arrived on. Seats
* start filled with AI and are handed over to people as they join, which is
* what makes "AI, humans, or any combination" fall out for free: whatever is
* still AI when the host starts, stays AI.
*/
class LobbyController(
context: Context,
@@ -40,9 +39,8 @@ class LobbyController(
)
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())
val state: StateFlow<State> = _state.asStateFlow()
@@ -8,7 +8,7 @@ import org.prolibertate.games.game.engine.PlayerSlot
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
* [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
/** 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_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 {
ignoreUnknownKeys = true
encodeDefaults = true
@@ -19,12 +19,11 @@ import java.util.concurrent.atomic.AtomicBoolean
enum class TransportKind(val label: String) {
LAN("Wi-Fi"),
BLUETOOTH("Bluetooth"),
}
/**
* 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(
val id: String,
@@ -37,8 +36,8 @@ data class DiscoveredHost(
/**
* One peer-to-peer link carrying [NetMessage]s.
*
* Both transports end up with a plain pair of streams, so the framing and
* serialisation live here once rather than twice.
* A transport ends up with a plain pair of streams, so the framing and
* serialisation live here rather than in each transport.
*/
interface Connection : Closeable {
val peerId: String
@@ -152,9 +151,8 @@ class StreamConnection(
}
/**
* Discovery and connection setup. Everything after the handshake is the same
* for both transports, which is what lets a lobby accept players over Wi-Fi and
* Bluetooth at the same time.
* Discovery and connection setup. Everything after the handshake is transport
* independent, so a lobby accepts players without caring how they arrived.
*/
interface Transport {
@@ -48,7 +48,6 @@ class AppEnv(
val updater: Updater,
val lobby: LobbyController,
val peerId: String,
val requestBluetoothPermissions: () -> Unit,
)
/**
@@ -30,8 +30,8 @@ import org.prolibertate.games.net.seatForDevice
/**
* Hosting or joining a table.
*
* A host advertises on Wi-Fi and Bluetooth simultaneously, and the seat list
* does not care which one a given player arrived on.
* A host advertises on every available transport, and the seat list does not
* care which one a given player arrived on.
*/
@Composable
fun LobbyScreen(
@@ -44,7 +44,6 @@ fun LobbyScreen(
val state by env.lobby.state.collectAsState()
LaunchedEffect(route) {
env.requestBluetoothPermissions()
if (route.hosting) {
// Seat the table the host actually set up, not the catalogue
// minimum: the rules engine rejects a table of the wrong size.
@@ -80,7 +79,7 @@ fun LobbyScreen(
val available = env.lobby.availableTransports()
Text(
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 {
(if (route.hosting) "Visible over " else "Searching over ") +
available.joinToString(" and ") { it.kind.label } + "."
@@ -157,8 +156,7 @@ private fun JoiningBody(
if (discovered.isEmpty()) {
Text("No games found yet. Make sure the host has started theirs.")
Text(
text = "Bluetooth only shows devices already paired in Android's " +
"Bluetooth settings.",
text = "Both devices need to be on the same Wi-Fi network.",
style = MaterialTheme.typography.bodySmall,
)
return
@@ -33,10 +33,10 @@ class SeatForDeviceTest {
* The regression this exists for.
*
* 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
* MAC over Bluetooth. A guest has never seen that value and so never matched
* it, and every guest fell through to "the first seat held by somebody
* remote". With one guest that is right by luck. With two, both of them
* the host's own transport made up for the incoming link — an IP on Wi-Fi. A
* guest has never seen that value and so never matched it, and every guest
* fell through to "the first seat held by somebody remote". With one guest
* 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
* table with no cards in front of it.
*/