diff --git a/README.md b/README.md index a8d08af..db40f69 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Pro Libertate Games 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 @@ -24,19 +24,13 @@ layers are game-agnostic and need no changes. ## Playing with other people 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 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. - **Wi-Fi (LAN)** — both devices must be on the same network. Discovery uses 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 @@ -207,7 +201,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested crazyeights/ Crazy 8s model, rules, AI chess/ Chess model, FEN, move generation, search 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 update/ GitHub Releases OTA updater ui/ Compose screens diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1027e3..94c7eeb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,7 +101,7 @@ dependencies { // Settings persistence for sound / animation speed / update preferences. 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-coroutines-android:1.8.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4e8e6f9..74eb70f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -10,25 +9,7 @@ - - - - - - - + = 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 diff --git a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt index 52a3665..89d8eb4 100644 --- a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt +++ b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt @@ -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. */ diff --git a/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt b/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt deleted file mode 100644 index a39d74e..0000000 --- a/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt +++ /dev/null @@ -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 = 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> = 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 - } -} diff --git a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt index f2da41f..22cefd2 100644 --- a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt +++ b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt @@ -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 = listOf(lan, bluetooth) + val transports: List = listOf(lan) private val _state = MutableStateFlow(State()) val state: StateFlow = _state.asStateFlow() diff --git a/app/src/main/java/org/prolibertate/games/net/Protocol.kt b/app/src/main/java/org/prolibertate/games/net/Protocol.kt index 8432247..789bb7f 100644 --- a/app/src/main/java/org/prolibertate/games/net/Protocol.kt +++ b/app/src/main/java/org/prolibertate/games/net/Protocol.kt @@ -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 diff --git a/app/src/main/java/org/prolibertate/games/net/Transport.kt b/app/src/main/java/org/prolibertate/games/net/Transport.kt index 13d8a59..8e9a6b0 100644 --- a/app/src/main/java/org/prolibertate/games/net/Transport.kt +++ b/app/src/main/java/org/prolibertate/games/net/Transport.kt @@ -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 { diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index cf8d4c0..acde3b5 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -48,7 +48,6 @@ class AppEnv( val updater: Updater, val lobby: LobbyController, val peerId: String, - val requestBluetoothPermissions: () -> Unit, ) /** diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt index a598769..9a001f2 100644 --- a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -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 diff --git a/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt b/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt index 210ecc6..7d27e83 100644 --- a/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt +++ b/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt @@ -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. */