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:
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user