From 907d007cff05f0ce4c2225a6e427aa0ee0868090 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Wed, 30 Sep 2026 10:50:15 +0200 Subject: [PATCH] Connect to offline-mode Minecraft servers --- README.md | 13 +- app/build.gradle.kts | 4 + .../craftconnect/CraftConnectApp.kt | 33 ++- .../craftconnect/MainActivity.kt | 3 +- .../craftconnect/data/DemoRepository.kt | 6 +- .../data/session/DefaultSessionManager.kt | 111 ++++++++ .../session/DefaultSessionManagerFactory.kt | 9 + .../domain/session/SessionManager.kt | 19 ++ .../domain/session/SessionModels.kt | 43 +++ .../protocol/MinecraftConnection.kt | 30 ++- .../LegacyOfflineMinecraftConnection.kt | 247 ++++++++++++++++++ .../protocol/legacy/ProtocolCodec.kt | 114 ++++++++ .../craftconnect/ui/screens/ChatScreen.kt | 3 +- .../craftconnect/ui/screens/ServersScreen.kt | 86 +++++- app/src/main/res/values/strings.xml | 9 +- .../data/session/DefaultSessionManagerTest.kt | 82 ++++++ .../LegacyOfflineMinecraftConnectionTest.kt | 68 +++++ .../protocol/legacy/ProtocolCodecTest.kt | 41 +++ docs/ARCHITECTURE.md | 16 +- gradle/libs.versions.toml | 5 + 20 files changed, 910 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerFactory.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionModels.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnection.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodec.kt create mode 100644 app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt create mode 100644 app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnectionTest.kt create mode 100644 app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodecTest.kt diff --git a/README.md b/README.md index d3e3211..699b0ec 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,26 @@ Implemented now: - Kotlin + Jetpack Compose Android project, - dark crimson / neon design system, - saved server browser, -- mock connect flow, +- direct offline-mode connection using Minecraft Java 1.8.x protocol 47, - chat and command surface, - online player list, - settings screen, - reusable UI components, +- observable session lifecycle and typed diagnostic errors, - protocol abstraction ready for a real implementation, - Gradle Wrapper, - GitHub Actions Android CI, - repository guidance in `AGENTS.md`. -Networking is intentionally not implemented yet. The current interface uses demo data. +The first networking slice supports direct TCP login to an offline-mode server using +Minecraft Java 1.8.x protocol 47, compression negotiation, keep-alive replies and the +initial position acknowledgement. Modern servers need a compatibility layer such as +ViaVersion until a current protocol adapter is implemented. + +The client sends only the selected nickname. In offline mode the server or proxy owns +identity assignment and returns the UUID during login; CraftConnect does not allow an +arbitrary UUID to be injected. Permission data tied to an offline UUID is only as secure +as the server's nickname protection, so this mode must not be exposed as authentication. ## Toolchain diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5e83e9..ff02dad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.kotlinx.coroutines.core) val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) @@ -46,4 +47,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt index 222629e..84bbab0 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt @@ -8,16 +8,21 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import pl.syntaxdevteam.craftconnect.data.DemoRepository import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile +import pl.syntaxdevteam.craftconnect.domain.session.ConnectionState +import pl.syntaxdevteam.craftconnect.domain.session.SessionManager +import pl.syntaxdevteam.craftconnect.domain.session.SessionManagerFactory import pl.syntaxdevteam.craftconnect.ui.components.CraftBackground import pl.syntaxdevteam.craftconnect.ui.components.CraftBottomBar import pl.syntaxdevteam.craftconnect.ui.screens.ChatScreen import pl.syntaxdevteam.craftconnect.ui.screens.PlayersScreen import pl.syntaxdevteam.craftconnect.ui.screens.ServersScreen import pl.syntaxdevteam.craftconnect.ui.screens.SettingsScreen +import kotlinx.coroutines.launch enum class AppDestination { Servers, @@ -27,9 +32,12 @@ enum class AppDestination { } @Composable -fun CraftConnectApp() { +fun CraftConnectApp(sessionManagerFactory: SessionManagerFactory) { var destination by remember { mutableStateOf(AppDestination.Servers) } var activeServer by remember { mutableStateOf(null) } + var sessionManager by remember { mutableStateOf(null) } + var connectionState by remember { mutableStateOf(ConnectionState.DISCONNECTED) } + val coroutineScope = rememberCoroutineScope() CraftBackground { Scaffold( @@ -46,12 +54,27 @@ fun CraftConnectApp() { Box(Modifier.padding(contentPadding)) { when (destination) { AppDestination.Servers -> ServersScreen( - onConnect = { server -> - activeServer = server - destination = AppDestination.Chat + connectionState = connectionState, + onConnect = { server, username -> + coroutineScope.launch { + val manager = sessionManagerFactory.create() + sessionManager = manager + connectionState = ConnectionState.CONNECTING + manager.connect(server, username) + connectionState = manager.session.value.connectionState + if (connectionState == ConnectionState.CONNECTED) { + activeServer = server.copy(online = true) + destination = AppDestination.Chat + } + } }, ) - AppDestination.Chat -> ChatScreen(currentServer) + AppDestination.Chat -> ChatScreen(currentServer) { message -> + coroutineScope.launch { + val manager = sessionManager ?: return@launch + if (message.startsWith('/')) manager.sendCommand(message) else manager.sendChat(message) + } + } AppDestination.Players -> PlayersScreen(currentServer) AppDestination.Settings -> SettingsScreen() } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt index 735fc47..8369f81 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt @@ -3,6 +3,7 @@ package pl.syntaxdevteam.craftconnect import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import pl.syntaxdevteam.craftconnect.data.session.DefaultSessionManagerFactory import pl.syntaxdevteam.craftconnect.ui.theme.CraftConnectTheme class MainActivity : ComponentActivity() { @@ -10,7 +11,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { CraftConnectTheme { - CraftConnectApp() + CraftConnectApp(DefaultSessionManagerFactory()) } } } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/DemoRepository.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/DemoRepository.kt index 285082f..b920082 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/DemoRepository.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/DemoRepository.kt @@ -7,11 +7,7 @@ import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile object DemoRepository { val servers = listOf( - ServerProfile("hypixel", "Hypixel", "mc.hypixel.net", true, 184320, 200000, 46, true), - ServerProfile("minemen", "Minemen Club", "minemen.club", true, 1203, 5000, 31), - ServerProfile("mcci", "MCCI", "play.mccisland.net", true, 3421, 10000, 55), - ServerProfile("cubecraft", "CubeCraft", "play.cubecraft.net", true, 2876, 20000, 63), - ServerProfile("local", "Local Server", "192.168.1.100:25565", false, 0, 20, null), + ServerProfile("local", "Local Server", "10.0.2.2:25565", false, 0, 0, null, true), ) val messages = listOf( diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt new file mode 100644 index 0000000..d6255d5 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt @@ -0,0 +1,111 @@ +package pl.syntaxdevteam.craftconnect.data.session + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile +import pl.syntaxdevteam.craftconnect.domain.session.ConnectionState +import pl.syntaxdevteam.craftconnect.domain.session.SessionError +import pl.syntaxdevteam.craftconnect.domain.session.SessionEvent +import pl.syntaxdevteam.craftconnect.domain.session.SessionManager +import pl.syntaxdevteam.craftconnect.domain.session.SessionSnapshot +import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnection +import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnectionException + +class DefaultSessionManager( + private val connection: MinecraftConnection, + private val clock: () -> Long = System::currentTimeMillis, +) : SessionManager { + private val operationMutex = Mutex() + private val mutableSession = MutableStateFlow(SessionSnapshot()) + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 16) + + override val session: StateFlow = mutableSession.asStateFlow() + override val events: SharedFlow = mutableEvents.asSharedFlow() + + override suspend fun connect(server: ServerProfile, username: String) = operationMutex.withLock { + if (session.value.connectionState != ConnectionState.DISCONNECTED && + session.value.connectionState != ConnectionState.FAILED + ) { + return@withLock + } + + update(SessionSnapshot(connectionState = ConnectionState.CONNECTING, server = server)) + try { + val connected = connection.connect(server, username) + update( + session.value.copy( + connectionState = ConnectionState.CONNECTED, + protocolVersion = connected.protocolVersion, + username = connected.username, + uuid = connected.uuid, + connectedAtEpochMillis = clock(), + lastError = null, + ), + ) + } catch (cancelled: CancellationException) { + update(SessionSnapshot()) + throw cancelled + } catch (failure: MinecraftConnectionException) { + fail(failure.toSessionError()) + } catch (_: Exception) { + fail(SessionError.Protocol("unexpected_connection_failure")) + } + } + + override suspend fun disconnect() = operationMutex.withLock { + if (session.value.connectionState == ConnectionState.DISCONNECTED) return@withLock + + var error: SessionError? = null + try { + connection.disconnect() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + error = SessionError.Disconnected("disconnect_cleanup_failure") + } + update(SessionSnapshot(lastError = error)) + mutableEvents.emit(SessionEvent.ConnectionClosed(error)) + } + + override suspend fun sendChat(message: String) { + requireConnected() + connection.sendChat(message) + } + + override suspend fun sendCommand(command: String) { + requireConnected() + connection.sendCommand(command) + } + + private fun requireConnected() { + check(session.value.connectionState == ConnectionState.CONNECTED) { + "A Minecraft session must be connected before sending data" + } + } + + private suspend fun fail(error: SessionError) { + update(session.value.copy(connectionState = ConnectionState.FAILED, lastError = error)) + mutableEvents.emit(SessionEvent.ConnectionFailed(error)) + } + + private suspend fun update(snapshot: SessionSnapshot) { + val previous = mutableSession.value.connectionState + mutableSession.value = snapshot + if (previous != snapshot.connectionState) { + mutableEvents.emit(SessionEvent.StateChanged(previous, snapshot.connectionState)) + } + } +} + +private fun MinecraftConnectionException.toSessionError(): SessionError = when (this) { + is MinecraftConnectionException.Network -> SessionError.Network(diagnosticCode) + is MinecraftConnectionException.Authentication -> SessionError.Authentication(diagnosticCode) + is MinecraftConnectionException.Protocol -> SessionError.Protocol(diagnosticCode) +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerFactory.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerFactory.kt new file mode 100644 index 0000000..41d1dc2 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerFactory.kt @@ -0,0 +1,9 @@ +package pl.syntaxdevteam.craftconnect.data.session + +import pl.syntaxdevteam.craftconnect.domain.session.SessionManager +import pl.syntaxdevteam.craftconnect.domain.session.SessionManagerFactory +import pl.syntaxdevteam.craftconnect.protocol.legacy.LegacyOfflineMinecraftConnection + +class DefaultSessionManagerFactory : SessionManagerFactory { + override fun create(): SessionManager = DefaultSessionManager(LegacyOfflineMinecraftConnection()) +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt new file mode 100644 index 0000000..9b5ae85 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt @@ -0,0 +1,19 @@ +package pl.syntaxdevteam.craftconnect.domain.session + +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile + +interface SessionManager { + val session: StateFlow + val events: SharedFlow + + suspend fun connect(server: ServerProfile, username: String) + suspend fun disconnect() + suspend fun sendChat(message: String) + suspend fun sendCommand(command: String) +} + +fun interface SessionManagerFactory { + fun create(): SessionManager +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionModels.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionModels.kt new file mode 100644 index 0000000..1c080f7 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionModels.kt @@ -0,0 +1,43 @@ +package pl.syntaxdevteam.craftconnect.domain.session + +import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile + +enum class ConnectionState { + DISCONNECTED, + CONNECTING, + AUTHENTICATING, + JOINING, + CONNECTED, + RECONNECTING, + FAILED, +} + +data class SessionSnapshot( + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, + val server: ServerProfile? = null, + val protocolVersion: Int? = null, + val username: String? = null, + val uuid: String? = null, + val connectedAtEpochMillis: Long? = null, + val reconnectCount: Int = 0, + val lastError: SessionError? = null, +) + +sealed interface SessionError { + val diagnosticCode: String + + data class Network(override val diagnosticCode: String) : SessionError + data class Authentication(override val diagnosticCode: String) : SessionError + data class Protocol(override val diagnosticCode: String) : SessionError + data class Disconnected(override val diagnosticCode: String) : SessionError +} + +sealed interface SessionEvent { + data class StateChanged( + val previous: ConnectionState, + val current: ConnectionState, + ) : SessionEvent + + data class ConnectionFailed(val error: SessionError) : SessionEvent + data class ConnectionClosed(val error: SessionError?) : SessionEvent +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt index 0fc129e..ef1d34e 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt @@ -2,24 +2,28 @@ package pl.syntaxdevteam.craftconnect.protocol import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile -sealed interface ConnectionState { - data object Disconnected : ConnectionState - data class Connecting(val server: ServerProfile) : ConnectionState - data class Connected(val server: ServerProfile, val protocolVersion: Int) : ConnectionState - data class Failed(val reason: String) : ConnectionState -} +data class ConnectedSession( + val protocolVersion: Int, + val username: String, + val uuid: String, +) /** - * Stable boundary between Android presentation code and the future headless - * Minecraft protocol implementation. - * - * Compose screens must never depend directly on packet classes. + * Packet-independent boundary implemented by a version-aware protocol adapter. + * Presentation code communicates through SessionManager instead of this type. */ interface MinecraftConnection { - val state: ConnectionState - - suspend fun connect(server: ServerProfile) + suspend fun connect(server: ServerProfile, username: String): ConnectedSession suspend fun disconnect() suspend fun sendChat(message: String) suspend fun sendCommand(command: String) } + +sealed class MinecraftConnectionException( + val diagnosticCode: String, + cause: Throwable? = null, +) : Exception(diagnosticCode, cause) { + class Network(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause) + class Authentication(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause) + class Protocol(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause) +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnection.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnection.kt new file mode 100644 index 0000000..ca0fa90 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnection.kt @@ -0,0 +1,247 @@ +package pl.syntaxdevteam.craftconnect.protocol.legacy + +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile +import pl.syntaxdevteam.craftconnect.protocol.ConnectedSession +import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnection +import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnectionException + +/** Minimal Minecraft Java 1.8.x (protocol 47) offline-mode client. */ +class LegacyOfflineMinecraftConnection( + private val connectTimeoutMillis: Int = 15_000, +) : MinecraftConnection { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val writeLock = Any() + private val open = AtomicBoolean(false) + private var socket: Socket? = null + private var input: BufferedInputStream? = null + private var output: BufferedOutputStream? = null + private var compressionThreshold: Int? = null + private var readerJob: Job? = null + private var x = 0.0 + private var y = 0.0 + private var z = 0.0 + private var yaw = 0f + private var pitch = 0f + + override suspend fun connect(server: ServerProfile, username: String): ConnectedSession = withContext(Dispatchers.IO) { + validateUsername(username) + val endpoint = ServerEndpoint.parse(server.address) + try { + val connectedSocket = Socket().apply { + tcpNoDelay = true + soTimeout = connectTimeoutMillis + connect(InetSocketAddress(endpoint.host, endpoint.port), connectTimeoutMillis) + } + socket = connectedSocket + input = BufferedInputStream(connectedSocket.getInputStream()) + output = BufferedOutputStream(connectedSocket.getOutputStream()) + open.set(true) + + sendHandshake(endpoint) + sendPacket(packet { + writeVarInt(LOGIN_START_PACKET) + writeProtocolString(username) + }) + val session = awaitLoginSuccess() + connectedSocket.soTimeout = 0 + readerJob = scope.launch { playLoop() } + session + } catch (failure: MinecraftConnectionException) { + closeResources() + throw failure + } catch (failure: ProtocolCodecException) { + closeResources() + throw MinecraftConnectionException.Protocol(failure.code, failure) + } catch (failure: Exception) { + closeResources() + throw MinecraftConnectionException.Network("connection_failed", failure) + } + } + + override suspend fun disconnect() = withContext(Dispatchers.IO) { + closeResources() + } + + override suspend fun sendChat(message: String) = withContext(Dispatchers.IO) { + require(message.length <= 100) { "Minecraft 1.8 chat is limited to 100 characters" } + sendPacket(packet { + writeVarInt(SERVERBOUND_CHAT_PACKET) + writeProtocolString(message) + }) + } + + override suspend fun sendCommand(command: String) { + sendChat(if (command.startsWith('/')) command else "/$command") + } + + private fun sendHandshake(endpoint: ServerEndpoint) { + sendPacket(packet { + writeVarInt(HANDSHAKE_PACKET) + writeVarInt(PROTOCOL_VERSION) + writeProtocolString(endpoint.host) + writeShort(endpoint.port) + writeVarInt(LOGIN_STATE) + }) + } + + private fun awaitLoginSuccess(): ConnectedSession { + while (open.get()) { + val packetInput = readPacket(requireInput(), compressionThreshold) + when (val packetId = packetInput.readVarInt()) { + LOGIN_DISCONNECT_PACKET -> { + packetInput.readProtocolString(MAX_JSON_LENGTH) + throw MinecraftConnectionException.Authentication("login_rejected") + } + LOGIN_SUCCESS_PACKET -> return ConnectedSession( + protocolVersion = PROTOCOL_VERSION, + uuid = packetInput.readProtocolString(64), + username = packetInput.readProtocolString(16), + ) + SET_COMPRESSION_PACKET -> compressionThreshold = packetInput.readVarInt().also { + if (it < 0) throw MinecraftConnectionException.Protocol("invalid_compression_threshold") + } + else -> throw MinecraftConnectionException.Protocol("unexpected_login_packet_$packetId") + } + } + throw MinecraftConnectionException.Network("connection_closed_during_login") + } + + private fun playLoop() { + try { + while (open.get()) { + val packetInput = readPacket(requireInput(), compressionThreshold) + when (packetInput.readVarInt()) { + CLIENTBOUND_KEEP_ALIVE_PACKET -> respondToKeepAlive(packetInput.readInt()) + CLIENTBOUND_POSITION_PACKET -> respondToPosition(packetInput) + CLIENTBOUND_DISCONNECT_PACKET -> { + packetInput.readProtocolString(MAX_JSON_LENGTH) + closeResources() + } + } + } + } catch (_: Exception) { + closeResources() + } + } + + private fun respondToKeepAlive(id: Int) { + sendPacket(packet { + writeVarInt(SERVERBOUND_KEEP_ALIVE_PACKET) + writeInt(id) + }) + } + + private fun respondToPosition(input: DataInputStream) { + val receivedX = input.readDouble() + val receivedY = input.readDouble() + val receivedZ = input.readDouble() + val receivedYaw = input.readFloat() + val receivedPitch = input.readFloat() + val flags = input.readUnsignedByte() + x = if (flags and RELATIVE_X != 0) x + receivedX else receivedX + y = if (flags and RELATIVE_Y != 0) y + receivedY else receivedY + z = if (flags and RELATIVE_Z != 0) z + receivedZ else receivedZ + yaw = if (flags and RELATIVE_YAW != 0) yaw + receivedYaw else receivedYaw + pitch = if (flags and RELATIVE_PITCH != 0) pitch + receivedPitch else receivedPitch + sendPacket(packet { + writeVarInt(SERVERBOUND_POSITION_LOOK_PACKET) + writeDouble(x) + writeDouble(y) + writeDouble(z) + writeFloat(yaw) + writeFloat(pitch) + writeBoolean(true) + }) + } + + private fun sendPacket(payload: ByteArray) { + check(open.get()) { "Connection is not open" } + synchronized(writeLock) { + requireOutput().apply { + write(frame(payload, compressionThreshold)) + flush() + } + } + } + + private fun closeResources() { + if (!open.getAndSet(false)) return + readerJob?.cancel() + runCatching { socket?.close() } + socket = null + input = null + output = null + compressionThreshold = null + x = 0.0 + y = 0.0 + z = 0.0 + yaw = 0f + pitch = 0f + } + + private fun requireInput() = checkNotNull(input) { "Connection input is unavailable" } + private fun requireOutput() = checkNotNull(output) { "Connection output is unavailable" } + + private fun validateUsername(username: String) { + if (!USERNAME.matches(username)) { + throw MinecraftConnectionException.Authentication("invalid_offline_username") + } + } + + private data class ServerEndpoint(val host: String, val port: Int) { + companion object { + fun parse(address: String): ServerEndpoint { + val value = address.trim() + if (value.startsWith("[") && value.contains("]")) { + val closingBracket = value.indexOf(']') + val host = value.substring(1, closingBracket) + val port = value.substring(closingBracket + 1).removePrefix(":").toIntOrNull() ?: DEFAULT_PORT + return ServerEndpoint(host, port) + } + val separator = value.lastIndexOf(':') + val explicitPort = separator > 0 && value.indexOf(':') == separator + val host = if (explicitPort) value.substring(0, separator) else value + val port = if (explicitPort) value.substring(separator + 1).toIntOrNull() else DEFAULT_PORT + require(host.isNotBlank() && port != null && port in 1..65_535) { "Invalid server address" } + return ServerEndpoint(host, port) + } + } + } + + private companion object { + const val PROTOCOL_VERSION = 47 + const val DEFAULT_PORT = 25_565 + const val MAX_JSON_LENGTH = 262_144 + const val HANDSHAKE_PACKET = 0x00 + const val LOGIN_STATE = 2 + const val LOGIN_START_PACKET = 0x00 + const val LOGIN_DISCONNECT_PACKET = 0x00 + const val LOGIN_SUCCESS_PACKET = 0x02 + const val SET_COMPRESSION_PACKET = 0x03 + const val CLIENTBOUND_KEEP_ALIVE_PACKET = 0x00 + const val CLIENTBOUND_POSITION_PACKET = 0x08 + const val CLIENTBOUND_DISCONNECT_PACKET = 0x40 + const val SERVERBOUND_KEEP_ALIVE_PACKET = 0x00 + const val SERVERBOUND_CHAT_PACKET = 0x01 + const val SERVERBOUND_POSITION_LOOK_PACKET = 0x06 + const val RELATIVE_X = 0x01 + const val RELATIVE_Y = 0x02 + const val RELATIVE_Z = 0x04 + const val RELATIVE_YAW = 0x08 + const val RELATIVE_PITCH = 0x10 + val USERNAME = Regex("[A-Za-z0-9_]{3,16}") + } +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodec.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodec.kt new file mode 100644 index 0000000..d956bf4 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodec.kt @@ -0,0 +1,114 @@ +package pl.syntaxdevteam.craftconnect.protocol.legacy + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.util.zip.Deflater +import java.util.zip.Inflater + +internal fun InputStream.readVarInt(): Int { + var result = 0 + var position = 0 + while (position < 32) { + val value = read() + if (value == -1) throw EOFException("Connection closed while reading VarInt") + result = result or ((value and 0x7F) shl position) + if (value and 0x80 == 0) return result + position += 7 + } + throw ProtocolCodecException("varint_too_long") +} + +internal fun DataInputStream.readProtocolString(maxLength: Int = 32_767): String { + val byteLength = readVarInt() + if (byteLength < 0 || byteLength > maxLength * 4) { + throw ProtocolCodecException("invalid_string_length") + } + val bytes = ByteArray(byteLength) + readFully(bytes) + return bytes.toString(StandardCharsets.UTF_8).also { + if (it.length > maxLength) throw ProtocolCodecException("invalid_string_length") + } +} + +internal fun DataOutputStream.writeVarInt(value: Int) { + var remaining = value + do { + var current = remaining and 0x7F + remaining = remaining ushr 7 + if (remaining != 0) current = current or 0x80 + writeByte(current) + } while (remaining != 0) +} + +internal fun DataOutputStream.writeProtocolString(value: String) { + val bytes = value.toByteArray(StandardCharsets.UTF_8) + writeVarInt(bytes.size) + write(bytes) +} + +internal fun packet(block: DataOutputStream.() -> Unit): ByteArray = + ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use(block) + bytes.toByteArray() + } + +internal fun frame(payload: ByteArray, compressionThreshold: Int?): ByteArray { + val body = if (compressionThreshold == null) { + payload + } else if (payload.size < compressionThreshold) { + packet { + writeVarInt(0) + write(payload) + } + } else { + val deflater = Deflater().apply { setInput(payload); finish() } + val compressed = ByteArrayOutputStream() + val buffer = ByteArray(8_192) + while (!deflater.finished()) compressed.write(buffer, 0, deflater.deflate(buffer)) + deflater.end() + packet { + writeVarInt(payload.size) + write(compressed.toByteArray()) + } + } + return packet { + writeVarInt(body.size) + write(body) + } +} + +internal fun readPacket(input: InputStream, compressionThreshold: Int?): DataInputStream { + val packetLength = input.readVarInt() + if (packetLength !in 1..MAX_PACKET_SIZE) throw ProtocolCodecException("invalid_packet_length") + val framed = ByteArray(packetLength) + DataInputStream(input).readFully(framed) + if (compressionThreshold == null) return DataInputStream(ByteArrayInputStream(framed)) + + val compressedInput = ByteArrayInputStream(framed) + val uncompressedLength = compressedInput.readVarInt() + if (uncompressedLength == 0) return DataInputStream(compressedInput) + if (uncompressedLength !in compressionThreshold..MAX_PACKET_SIZE) { + throw ProtocolCodecException("invalid_uncompressed_length") + } + val inflater = Inflater() + return try { + inflater.setInput(compressedInput.readBytes()) + val output = ByteArray(uncompressedLength) + val actualLength = inflater.inflate(output) + if (actualLength != uncompressedLength || !inflater.finished()) { + throw ProtocolCodecException("invalid_compressed_packet") + } + DataInputStream(ByteArrayInputStream(output)) + } finally { + inflater.end() + } +} + +internal class ProtocolCodecException(val code: String) : Exception(code) + +private const val MAX_PACKET_SIZE = 2 * 1024 * 1024 diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ChatScreen.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ChatScreen.kt index 6634d0c..09ec4aa 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ChatScreen.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ChatScreen.kt @@ -47,7 +47,7 @@ import pl.syntaxdevteam.craftconnect.ui.theme.TextSecondary import pl.syntaxdevteam.craftconnect.ui.theme.Warning @Composable -fun ChatScreen(server: ServerProfile) { +fun ChatScreen(server: ServerProfile, onSend: (String) -> Unit = {}) { val messages = remember { mutableStateListOf().apply { addAll(DemoRepository.messages) } } var input by remember { mutableStateOf("") } @@ -98,6 +98,7 @@ fun ChatScreen(server: ServerProfile) { onClick = { val value = input.trim() if (value.isNotEmpty()) { + onSend(value) messages += ChatMessage("You", value, "now") input = "" } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt index d9460ea..aec532c 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text @@ -35,6 +36,7 @@ import androidx.compose.ui.unit.sp import pl.syntaxdevteam.craftconnect.R import pl.syntaxdevteam.craftconnect.data.DemoRepository import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile +import pl.syntaxdevteam.craftconnect.domain.session.ConnectionState import pl.syntaxdevteam.craftconnect.ui.components.BrandHeader import pl.syntaxdevteam.craftconnect.ui.components.GlowButton import pl.syntaxdevteam.craftconnect.ui.components.NeonCard @@ -48,9 +50,14 @@ import pl.syntaxdevteam.craftconnect.ui.theme.TextPrimary import pl.syntaxdevteam.craftconnect.ui.theme.TextSecondary @Composable -fun ServersScreen(onConnect: (ServerProfile) -> Unit) { +fun ServersScreen( + connectionState: ConnectionState, + onConnect: (ServerProfile, String) -> Unit, +) { var query by remember { mutableStateOf("") } var favoritesOnly by remember { mutableStateOf(false) } + var connectionTarget by remember { mutableStateOf(null) } + var customConnection by remember { mutableStateOf(false) } val servers = DemoRepository.servers.filter { server -> (!favoritesOnly || server.favorite) && @@ -85,7 +92,7 @@ fun ServersScreen(onConnect: (ServerProfile) -> Unit) { ), ) Spacer(Modifier.width(8.dp)) - IconButton(onClick = { }) { + IconButton(onClick = { customConnection = true }) { Icon( Icons.Rounded.Add, contentDescription = stringResource(R.string.add_server), @@ -107,10 +114,83 @@ fun ServersScreen(onConnect: (ServerProfile) -> Unit) { verticalArrangement = Arrangement.spacedBy(10.dp), ) { items(servers, key = { it.id }) { server -> - ServerRow(server, onConnect = { onConnect(server) }) + ServerRow(server, onConnect = { connectionTarget = server }) } } } + + val target = connectionTarget + if (target != null || customConnection) { + OfflineConnectDialog( + initialAddress = target?.address.orEmpty(), + connecting = connectionState == ConnectionState.CONNECTING, + failed = connectionState == ConnectionState.FAILED, + onDismiss = { + connectionTarget = null + customConnection = false + }, + onConnect = { address, username -> + val server = target ?: ServerProfile( + id = "custom-$address", + name = address, + address = address, + online = false, + playersOnline = 0, + playersMax = 0, + pingMs = null, + ) + onConnect(server.copy(address = address), username) + }, + ) + } +} + +@Composable +private fun OfflineConnectDialog( + initialAddress: String, + connecting: Boolean, + failed: Boolean, + onDismiss: () -> Unit, + onConnect: (String, String) -> Unit, +) { + var address by remember(initialAddress) { mutableStateOf(initialAddress) } + var username by remember { mutableStateOf("") } + val valid = address.isNotBlank() && Regex("[A-Za-z0-9_]{3,16}").matches(username) + + AlertDialog( + onDismissRequest = { if (!connecting) onDismiss() }, + title = { Text(stringResource(R.string.quick_connect)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = address, + onValueChange = { address = it }, + label = { Text(stringResource(R.string.server_address)) }, + singleLine = true, + enabled = !connecting, + ) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.offline_username)) }, + singleLine = true, + enabled = !connecting, + ) + Text(stringResource(R.string.legacy_protocol_notice), color = TextSecondary, fontSize = 12.sp) + if (failed) Text(stringResource(R.string.connection_failed), color = Crimson, fontSize = 12.sp) + } + }, + confirmButton = { + GlowButton( + if (connecting) stringResource(R.string.connecting) else stringResource(R.string.connect), + onClick = { if (valid && !connecting) onConnect(address.trim(), username) }, + ) + }, + dismissButton = { + GlowButton(stringResource(R.string.cancel), onClick = onDismiss) + }, + containerColor = SurfaceRaised, + ) } @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b041404..014acac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,13 @@ All Favorites Connect + Offline-mode connection + Server address + Offline username + Cancel + Uses Minecraft Java 1.8.x protocol (47). A newer server requires ViaVersion or equivalent compatibility. + Connecting… + Connection failed. Check the address, server mode, protocol compatibility and username. Connected Online Offline @@ -49,5 +56,5 @@ Local profile Saved servers Import, export or clear servers - CraftConnect 0.1.0 · UI skeleton + CraftConnect 0.1.0 · Offline protocol preview diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt new file mode 100644 index 0000000..7da2386 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt @@ -0,0 +1,82 @@ +package pl.syntaxdevteam.craftconnect.data.session + +import kotlinx.coroutines.async +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile +import pl.syntaxdevteam.craftconnect.domain.session.ConnectionState +import pl.syntaxdevteam.craftconnect.domain.session.SessionError +import pl.syntaxdevteam.craftconnect.domain.session.SessionEvent +import pl.syntaxdevteam.craftconnect.protocol.ConnectedSession +import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnection +import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnectionException + +class DefaultSessionManagerTest { + @Test + fun successfulConnectionPublishesConnectedSession() = runTest { + val manager = DefaultSessionManager(FakeConnection(), clock = { 42L }) + + manager.connect(server, "CraftyDev") + + assertEquals(ConnectionState.CONNECTED, manager.session.value.connectionState) + assertEquals(47, manager.session.value.protocolVersion) + assertEquals("CraftyDev", manager.session.value.username) + assertEquals("01234567-89ab-cdef-0123-456789abcdef", manager.session.value.uuid) + assertEquals(42L, manager.session.value.connectedAtEpochMillis) + assertNull(manager.session.value.lastError) + } + + @Test + fun typedProtocolFailureBecomesSafeDomainError() = runTest { + val manager = DefaultSessionManager( + FakeConnection(connectFailure = MinecraftConnectionException.Network("tcp_timeout")), + ) + val failureEvent = async(start = CoroutineStart.UNDISPATCHED) { + manager.events.first { it is SessionEvent.ConnectionFailed } + } + + manager.connect(server, "CraftyDev") + + assertEquals(ConnectionState.FAILED, manager.session.value.connectionState) + assertEquals(SessionError.Network("tcp_timeout"), manager.session.value.lastError) + assertEquals( + SessionError.Network("tcp_timeout"), + (failureEvent.await() as SessionEvent.ConnectionFailed).error, + ) + } + + @Test + fun sendingRequiresConnectedSession() = runTest { + val manager = DefaultSessionManager(FakeConnection()) + + val failure = runCatching { manager.sendChat("hello") }.exceptionOrNull() + + assertTrue(failure is IllegalStateException) + } + + private class FakeConnection( + private val connectFailure: MinecraftConnectionException? = null, + ) : MinecraftConnection { + override suspend fun connect(server: ServerProfile, username: String): ConnectedSession { + connectFailure?.let { throw it } + return ConnectedSession( + protocolVersion = 47, + username = username, + uuid = "01234567-89ab-cdef-0123-456789abcdef", + ) + } + + override suspend fun disconnect() = Unit + override suspend fun sendChat(message: String) = Unit + override suspend fun sendCommand(command: String) = Unit + } + + private companion object { + val server = ServerProfile("test", "Test", "localhost", true, 0, 20, 1) + } +} diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnectionTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnectionTest.kt new file mode 100644 index 0000000..9b8eea6 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnectionTest.kt @@ -0,0 +1,68 @@ +package pl.syntaxdevteam.craftconnect.protocol.legacy + +import java.net.ServerSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Test +import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile + +class LegacyOfflineMinecraftConnectionTest { + @Test + fun logsInAndRepliesToKeepAlive() = runBlocking { + ServerSocket(0).use { serverSocket -> + val server = async(Dispatchers.IO) { + serverSocket.accept().use { client -> + val input = client.getInputStream() + val output = client.getOutputStream() + val handshake = readPacket(input, null) + assertEquals(0, handshake.readVarInt()) + assertEquals(47, handshake.readVarInt()) + handshake.readProtocolString() + handshake.readUnsignedShort() + assertEquals(2, handshake.readVarInt()) + + val login = readPacket(input, null) + assertEquals(0, login.readVarInt()) + assertEquals("OfflineUser", login.readProtocolString(16)) + output.write(frame(packet { + writeVarInt(2) + writeProtocolString("01234567-89ab-cdef-0123-456789abcdef") + writeProtocolString("OfflineUser") + }, null)) + output.flush() + + output.write(frame(packet { + writeVarInt(0) + writeInt(123_456) + }, null)) + output.flush() + val keepAlive = readPacket(input, null) + assertEquals(0, keepAlive.readVarInt()) + assertEquals(123_456, keepAlive.readInt()) + } + } + val connection = LegacyOfflineMinecraftConnection(connectTimeoutMillis = 2_000) + val profile = ServerProfile( + id = "test", + name = "Test", + address = "127.0.0.1:${serverSocket.localPort}", + online = false, + playersOnline = 0, + playersMax = 0, + pingMs = null, + ) + + val session = connection.connect(profile, "OfflineUser") + + assertEquals(47, session.protocolVersion) + assertEquals("OfflineUser", session.username) + assertEquals("01234567-89ab-cdef-0123-456789abcdef", session.uuid) + withTimeout(2_000) { server.await() } + withContext(Dispatchers.IO) { connection.disconnect() } + } + } +} diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodecTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodecTest.kt new file mode 100644 index 0000000..08930d4 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/ProtocolCodecTest.kt @@ -0,0 +1,41 @@ +package pl.syntaxdevteam.craftconnect.protocol.legacy + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProtocolCodecTest { + @Test + fun varIntRoundTripsBoundaryValues() { + listOf(0, 1, 127, 128, 255, 2_097_151, Int.MAX_VALUE, -1).forEach { value -> + val encoded = ByteArrayOutputStream().also { DataOutputStream(it).writeVarInt(value) }.toByteArray() + + assertEquals(value, ByteArrayInputStream(encoded).readVarInt()) + } + } + + @Test + fun uncompressedPacketRoundTripsWhenCompressionIsDisabled() { + val payload = packet { + writeVarInt(2) + writeProtocolString("CraftConnect") + } + + val decoded = readPacket(ByteArrayInputStream(frame(payload, null)), null) + + assertEquals(2, decoded.readVarInt()) + assertEquals("CraftConnect", decoded.readProtocolString()) + } + + @Test + fun compressedPacketRoundTripsAboveThreshold() { + val payload = ByteArray(4_096) { (it % 251).toByte() } + + val decoded = readPacket(ByteArrayInputStream(frame(payload, 256)), 256) + + assertArrayEquals(payload, decoded.readBytes()) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e93d44a..07a738e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,14 +5,28 @@ CraftConnect is an Android application that behaves as a lightweight Minecraft p ## Layers - **ui/** — Jetpack Compose screens, visual components and theme. -- **domain/model/** — app-level models independent from packet implementations. +- **domain/model/** — app-level content models independent from packet implementations. +- **domain/session/** — observable session state, lifecycle events and the UI-facing session contract. - **data/** — repositories, persistence and external data adapters. - **protocol/** — Minecraft connection and packet handling boundary. The UI must not depend directly on packet classes. +`DefaultSessionManager` is the adapter between the domain-facing `SessionManager` and +the packet-independent `MinecraftConnection`. It serializes lifecycle operations, +publishes state through `StateFlow`, emits one-off lifecycle events through +`SharedFlow`, and maps protocol failures to diagnostic domain errors without exposing +exception messages that could contain server or account data. + ## Initial protocol scope +The first concrete adapter is deliberately narrow: `LegacyOfflineMinecraftConnection` +implements offline-mode login for Java protocol 47 (Minecraft 1.8.x), compression, +keep-alive, the initial position response, chat and commands. It is useful directly on +legacy servers and through protocol translators, but it is not an "auto/latest" client. +Additional versions belong in separate adapters rather than conditional packet IDs in +the legacy implementation. + The first real networking milestone should implement only what the product needs: 1. Server status and handshake. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3f12e5..d257e99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,8 @@ coreKtx = "1.16.0" activityCompose = "1.10.1" lifecycle = "2.9.2" composeBom = "2025.06.01" +coroutines = "1.10.2" +junit = "4.13.2" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -17,6 +19,9 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +junit = { module = "junit:junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }