From 32e85ba5cf33d3f88059366827896940f4a6d4a1 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Wed, 30 Sep 2026 13:10:34 +0200 Subject: [PATCH] Add session networking and vector branding --- README.md | 14 +- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 2 + .../craftconnect/CraftConnectApp.kt | 37 +- .../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 | 59 +++ .../protocol/MinecraftConnection.kt | 32 +- .../LegacyOfflineMinecraftConnection.kt | 276 ++++++++++++++ .../protocol/legacy/ProtocolCodec.kt | 114 ++++++ .../ModernOfflineMinecraftConnection.kt | 342 ++++++++++++++++++ .../ui/components/CraftComponents.kt | 9 +- .../craftconnect/ui/screens/ChatScreen.kt | 3 +- .../craftconnect/ui/screens/ServersScreen.kt | 95 ++++- .../main/res/drawable/craftconnect_logo.xml | 72 ++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values-v31/themes.xml | 13 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 10 +- .../data/session/DefaultSessionManagerTest.kt | 98 +++++ .../LegacyOfflineMinecraftConnectionTest.kt | 76 ++++ .../protocol/legacy/ProtocolCodecTest.kt | 41 +++ .../ModernOfflineMinecraftConnectionTest.kt | 90 +++++ docs/ARCHITECTURE.md | 14 +- gradle/libs.versions.toml | 5 + 29 files changed, 1535 insertions(+), 33 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/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnection.kt create mode 100644 app/src/main/res/drawable/craftconnect_logo.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values-v31/themes.xml create mode 100644 app/src/main/res/values/colors.xml 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 create mode 100644 app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnectionTest.kt diff --git a/README.md b/README.md index d3e3211..14611f9 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,28 @@ Implemented now: - Kotlin + Jetpack Compose Android project, - dark crimson / neon design system, +- branded launcher, adaptive and in-app logo assets, - saved server browser, -- mock connect flow, +- direct offline-mode connection using Minecraft Java 26.1 protocol 775, - 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 default networking adapter supports direct TCP login to an offline-mode server using +Minecraft Java 26.1 protocol 775, including the login, configuration and play states, +compression negotiation, keep-alive replies and position acknowledgement. The older +protocol 47 adapter remains isolated for later explicit version selection. + +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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d4bc987..1b9c4c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,9 @@ (null) } + var sessionManager by remember { mutableStateOf(null) } + var connectionState by remember { mutableStateOf(ConnectionState.DISCONNECTED) } + var connectionError by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() CraftBackground { Scaffold( @@ -46,12 +55,30 @@ fun CraftConnectApp() { Box(Modifier.padding(contentPadding)) { when (destination) { AppDestination.Servers -> ServersScreen( - onConnect = { server -> - activeServer = server - destination = AppDestination.Chat + connectionState = connectionState, + connectionError = connectionError, + onConnect = { server, username -> + coroutineScope.launch { + val manager = sessionManagerFactory.create() + sessionManager = manager + connectionState = ConnectionState.CONNECTING + connectionError = null + manager.connect(server, username) + connectionState = manager.session.value.connectionState + connectionError = manager.session.value.lastError?.serverMessage + 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..dafad30 --- /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, serverMessage) + is MinecraftConnectionException.Authentication -> SessionError.Authentication(diagnosticCode, serverMessage) + is MinecraftConnectionException.Protocol -> SessionError.Protocol(diagnosticCode, serverMessage) +} 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..a3dc77c --- /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.modern.ModernOfflineMinecraftConnection + +class DefaultSessionManagerFactory : SessionManagerFactory { + override fun create(): SessionManager = DefaultSessionManager(ModernOfflineMinecraftConnection()) +} 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..c19a711 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionModels.kt @@ -0,0 +1,59 @@ +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 + val serverMessage: String? + + data class Network( + override val diagnosticCode: String, + override val serverMessage: String? = null, + ) : SessionError + + data class Authentication( + override val diagnosticCode: String, + override val serverMessage: String? = null, + ) : SessionError + + data class Protocol( + override val diagnosticCode: String, + override val serverMessage: String? = null, + ) : SessionError + + data class Disconnected( + override val diagnosticCode: String, + override val serverMessage: String? = null, + ) : 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..b4df817 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,30 @@ 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, + val serverMessage: String? = null, + cause: Throwable? = null, +) : Exception(diagnosticCode, cause) { + class Network(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause = cause) + class Authentication(code: String, serverMessage: String? = null) : + MinecraftConnectionException(code, serverMessage) + class Protocol(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause = 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..c6d1633 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnection.kt @@ -0,0 +1,276 @@ +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 + sendClientIdentity() + 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 -> { + val reason = packetInput.readProtocolString(MAX_JSON_LENGTH).minecraftText() + throw MinecraftConnectionException.Authentication("login_rejected", reason) + } + 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.readVarInt()) + 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) + writeVarInt(id) + }) + } + + private fun sendClientIdentity() { + sendPacket(packet { + writeVarInt(SERVERBOUND_CLIENT_SETTINGS_PACKET) + writeProtocolString("en_US") + writeByte(2) + writeByte(0) + writeBoolean(true) + writeByte(0x7F) + }) + sendPacket(packet { + writeVarInt(SERVERBOUND_CUSTOM_PAYLOAD_PACKET) + writeProtocolString("MC|Brand") + writeProtocolString("CraftConnect") + }) + } + + 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 SERVERBOUND_CLIENT_SETTINGS_PACKET = 0x15 + const val SERVERBOUND_CUSTOM_PAYLOAD_PACKET = 0x17 + 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}") + } +} + +private fun String.minecraftText(): String { + val text = Regex("\"text\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"").find(this)?.groupValues?.get(1) + ?: return take(512) + return text + .replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\\\", "\\") + .take(512) +} 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/protocol/modern/ModernOfflineMinecraftConnection.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnection.kt new file mode 100644 index 0000000..e2fbee9 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnection.kt @@ -0,0 +1,342 @@ +package pl.syntaxdevteam.craftconnect.protocol.modern + +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.charset.StandardCharsets +import java.util.UUID +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 +import pl.syntaxdevteam.craftconnect.protocol.legacy.ProtocolCodecException +import pl.syntaxdevteam.craftconnect.protocol.legacy.frame +import pl.syntaxdevteam.craftconnect.protocol.legacy.packet +import pl.syntaxdevteam.craftconnect.protocol.legacy.readPacket +import pl.syntaxdevteam.craftconnect.protocol.legacy.readProtocolString +import pl.syntaxdevteam.craftconnect.protocol.legacy.readVarInt +import pl.syntaxdevteam.craftconnect.protocol.legacy.writeProtocolString +import pl.syntaxdevteam.craftconnect.protocol.legacy.writeVarInt + +/** Headless offline-mode adapter for Minecraft Java 26.1 (protocol 775). */ +class ModernOfflineMinecraftConnection( + 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 = Endpoint.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) + sendLoginStart(username) + val session = awaitLoginSuccess() + sendPacket(packet { writeVarInt(LOGIN_ACKNOWLEDGED) }) + sendClientSettings(CONFIGURATION_CLIENT_SETTINGS) + awaitConfiguration() + 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 <= 256) { "Chat is limited to 256 characters" } + sendPacket(packet { + writeVarInt(SERVERBOUND_CHAT) + writeProtocolString(message) + writeLong(System.currentTimeMillis()) + writeLong(0L) + writeBoolean(false) + writeVarInt(0) + write(byteArrayOf(0, 0, 0)) + writeByte(0) + }) + } + + override suspend fun sendCommand(command: String) = withContext(Dispatchers.IO) { + sendPacket(packet { + writeVarInt(SERVERBOUND_COMMAND) + writeProtocolString(command.removePrefix("/")) + }) + } + + private fun sendHandshake(endpoint: Endpoint) = sendPacket(packet { + writeVarInt(HANDSHAKE) + writeVarInt(PROTOCOL_VERSION) + writeProtocolString(endpoint.host) + writeShort(endpoint.port) + writeVarInt(LOGIN_STATE) + }) + + private fun sendLoginStart(username: String) = sendPacket(packet { + writeVarInt(LOGIN_START) + writeProtocolString(username) + writeUuid(offlineUuid(username)) + }) + + private fun awaitLoginSuccess(): ConnectedSession { + while (open.get()) { + val packetInput = readPacket(requireInput(), compressionThreshold) + when (val packetId = packetInput.readVarInt()) { + LOGIN_DISCONNECT -> throw MinecraftConnectionException.Authentication( + "login_rejected", + packetInput.readProtocolString(MAX_TEXT_LENGTH).minecraftText(), + ) + LOGIN_SUCCESS -> { + val uuid = packetInput.readUuid() + val username = packetInput.readProtocolString(16) + repeat(packetInput.readVarInt()) { + packetInput.readProtocolString() + packetInput.readProtocolString() + if (packetInput.readBoolean()) packetInput.readProtocolString() + } + return ConnectedSession(PROTOCOL_VERSION, username, uuid.toString()) + } + SET_COMPRESSION -> compressionThreshold = packetInput.readVarInt().also { + if (it < 0) throw MinecraftConnectionException.Protocol("invalid_compression_threshold") + } + LOGIN_PLUGIN_REQUEST -> rejectLoginPlugin(packetInput) + else -> throw MinecraftConnectionException.Protocol("unexpected_login_packet_$packetId") + } + } + throw MinecraftConnectionException.Network("connection_closed_during_login") + } + + private fun rejectLoginPlugin(input: DataInputStream) { + val messageId = input.readVarInt() + sendPacket(packet { + writeVarInt(LOGIN_PLUGIN_RESPONSE) + writeVarInt(messageId) + writeBoolean(false) + }) + } + + private fun awaitConfiguration() { + while (open.get()) { + val packetInput = readPacket(requireInput(), compressionThreshold) + when (packetInput.readVarInt()) { + CONFIGURATION_DISCONNECT -> throw MinecraftConnectionException.Authentication( + "configuration_rejected", + "Server disconnected during configuration", + ) + CONFIGURATION_FINISH -> { + sendPacket(packet { writeVarInt(SERVERBOUND_CONFIGURATION_FINISH) }) + return + } + CONFIGURATION_KEEP_ALIVE -> respondLong(SERVERBOUND_CONFIGURATION_KEEP_ALIVE, packetInput.readLong()) + CONFIGURATION_PING -> respondInt(SERVERBOUND_CONFIGURATION_PONG, packetInput.readInt()) + CONFIGURATION_KNOWN_PACKS -> respondKnownPacks(packetInput) + CONFIGURATION_CODE_OF_CONDUCT -> sendPacket(packet { writeVarInt(SERVERBOUND_ACCEPT_CODE_OF_CONDUCT) }) + } + } + throw MinecraftConnectionException.Network("connection_closed_during_configuration") + } + + private fun respondKnownPacks(input: DataInputStream) { + val packs = List(input.readVarInt()) { + Triple(input.readProtocolString(), input.readProtocolString(), input.readProtocolString()) + } + sendPacket(packet { + writeVarInt(SERVERBOUND_KNOWN_PACKS) + writeVarInt(packs.size) + packs.forEach { (namespace, id, version) -> + writeProtocolString(namespace) + writeProtocolString(id) + writeProtocolString(version) + } + }) + } + + private fun playLoop() { + try { + while (open.get()) { + val packetInput = readPacket(requireInput(), compressionThreshold) + when (packetInput.readVarInt()) { + CLIENTBOUND_KEEP_ALIVE -> respondLong(SERVERBOUND_KEEP_ALIVE, packetInput.readLong()) + CLIENTBOUND_POSITION -> acknowledgePosition(packetInput) + CLIENTBOUND_DISCONNECT -> closeResources() + } + } + } catch (_: Exception) { + closeResources() + } + } + + private fun acknowledgePosition(input: DataInputStream) { + val teleportId = input.readVarInt() + val receivedX = input.readDouble() + val receivedY = input.readDouble() + val receivedZ = input.readDouble() + input.readDouble() + input.readDouble() + input.readDouble() + val receivedYaw = input.readFloat() + val receivedPitch = input.readFloat() + val flags = input.readInt() + 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_TELEPORT_CONFIRM); writeVarInt(teleportId) }) + sendPacket(packet { + writeVarInt(SERVERBOUND_POSITION_LOOK) + writeDouble(x); writeDouble(y); writeDouble(z) + writeFloat(yaw); writeFloat(pitch) + writeByte(1) + }) + } + + private fun sendClientSettings(packetId: Int) = sendPacket(packet { + writeVarInt(packetId) + writeProtocolString("en_US") + writeByte(2) + writeVarInt(0) + writeBoolean(true) + writeByte(0x7F) + writeVarInt(1) + writeBoolean(false) + writeBoolean(true) + writeVarInt(2) + }) + + private fun respondLong(packetId: Int, value: Long) = sendPacket(packet { writeVarInt(packetId); writeLong(value) }) + private fun respondInt(packetId: Int, value: Int) = sendPacket(packet { writeVarInt(packetId); writeInt(value) }) + + 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 Endpoint(val host: String, val port: Int) { + companion object { + fun parse(address: String): Endpoint { + val value = address.trim() + 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 Endpoint(host, port) + } + } + } + + private companion object { + const val PROTOCOL_VERSION = 775 + const val DEFAULT_PORT = 25_565 + const val MAX_TEXT_LENGTH = 262_144 + const val HANDSHAKE = 0x00 + const val LOGIN_STATE = 2 + const val LOGIN_START = 0x00 + const val LOGIN_DISCONNECT = 0x00 + const val LOGIN_SUCCESS = 0x02 + const val SET_COMPRESSION = 0x03 + const val LOGIN_PLUGIN_REQUEST = 0x04 + const val LOGIN_PLUGIN_RESPONSE = 0x02 + const val LOGIN_ACKNOWLEDGED = 0x03 + const val CONFIGURATION_CLIENT_SETTINGS = 0x00 + const val CONFIGURATION_DISCONNECT = 0x02 + const val CONFIGURATION_FINISH = 0x03 + const val CONFIGURATION_KEEP_ALIVE = 0x04 + const val CONFIGURATION_PING = 0x05 + const val CONFIGURATION_KNOWN_PACKS = 0x0E + const val CONFIGURATION_CODE_OF_CONDUCT = 0x13 + const val SERVERBOUND_CONFIGURATION_FINISH = 0x03 + const val SERVERBOUND_CONFIGURATION_KEEP_ALIVE = 0x04 + const val SERVERBOUND_CONFIGURATION_PONG = 0x05 + const val SERVERBOUND_KNOWN_PACKS = 0x07 + const val SERVERBOUND_ACCEPT_CODE_OF_CONDUCT = 0x09 + const val CLIENTBOUND_KEEP_ALIVE = 0x2C + const val CLIENTBOUND_DISCONNECT = 0x20 + const val CLIENTBOUND_POSITION = 0x48 + const val SERVERBOUND_TELEPORT_CONFIRM = 0x00 + const val SERVERBOUND_COMMAND = 0x07 + const val SERVERBOUND_CHAT = 0x09 + const val SERVERBOUND_KEEP_ALIVE = 0x1C + const val SERVERBOUND_POSITION_LOOK = 0x1F + 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}") + } +} + +private fun offlineUuid(username: String): UUID = + UUID.nameUUIDFromBytes("OfflinePlayer:$username".toByteArray(StandardCharsets.UTF_8)) + +private fun DataInputStream.readUuid(): UUID = UUID(readLong(), readLong()) +private fun java.io.DataOutputStream.writeUuid(uuid: UUID) { writeLong(uuid.mostSignificantBits); writeLong(uuid.leastSignificantBits) } + +private fun String.minecraftText(): String = + (Regex("\"text\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"").find(this)?.groupValues?.get(1) ?: take(512)) + .replace("\\n", "\n").replace("\\\"", "\"").replace("\\\\", "\\").take(512) diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/CraftComponents.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/CraftComponents.kt index 7d00205..507a279 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/CraftComponents.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/CraftComponents.kt @@ -2,6 +2,7 @@ package pl.syntaxdevteam.craftconnect.ui.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -34,6 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -41,6 +43,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -126,7 +129,11 @@ fun BrandHeader() { .padding(horizontal = 20.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - CraftMark() + Image( + painter = painterResource(R.drawable.craftconnect_logo), + contentDescription = stringResource(R.string.app_logo), + modifier = Modifier.size(46.dp).clip(RoundedCornerShape(12.dp)), + ) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { 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..cac98c6 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,15 @@ import pl.syntaxdevteam.craftconnect.ui.theme.TextPrimary import pl.syntaxdevteam.craftconnect.ui.theme.TextSecondary @Composable -fun ServersScreen(onConnect: (ServerProfile) -> Unit) { +fun ServersScreen( + connectionState: ConnectionState, + connectionError: String?, + 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 +93,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 +115,91 @@ 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, + connectionError = connectionError, + 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, + connectionError: String?, + 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.modern_protocol_notice), color = TextSecondary, fontSize = 12.sp) + if (failed) { + Text( + connectionError ?: 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/drawable/craftconnect_logo.xml b/app/src/main/res/drawable/craftconnect_logo.xml new file mode 100644 index 0000000..6e30e21 --- /dev/null +++ b/app/src/main/res/drawable/craftconnect_logo.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..4f1651e --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..4f1651e --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..7c5eec2 --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..355a41d --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #08090B + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b041404..cf85e23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ CraftConnect + CraftConnect logo LIGHTWEIGHT · FAST · CONNECTED Servers Chat @@ -12,6 +13,13 @@ All Favorites Connect + Offline-mode connection + Server address + Offline username + Cancel + Uses Minecraft Java 26.1 protocol (775). The server must allow offline-mode logins. + Connecting… + Connection failed. Check the address, server mode, protocol compatibility and username. Connected Online Offline @@ -49,5 +57,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..b2a5de6 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt @@ -0,0 +1,98 @@ +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 serverRejectionMessageIsPreservedForTheUi() = runTest { + val manager = DefaultSessionManager( + FakeConnection( + connectFailure = MinecraftConnectionException.Authentication( + "login_rejected", + "Outdated client!", + ), + ), + ) + + manager.connect(server, "CraftyDev") + + assertEquals("Outdated client!", manager.session.value.lastError?.serverMessage) + } + + @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..3c9a83b --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnectionTest.kt @@ -0,0 +1,76 @@ +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() + + val settings = readPacket(input, null) + assertEquals(0x15, settings.readVarInt()) + assertEquals("en_US", settings.readProtocolString()) + val brand = readPacket(input, null) + assertEquals(0x17, brand.readVarInt()) + assertEquals("MC|Brand", brand.readProtocolString()) + assertEquals("CraftConnect", brand.readProtocolString()) + + output.write(frame(packet { + writeVarInt(0) + writeVarInt(123_456) + }, null)) + output.flush() + val keepAlive = readPacket(input, null) + assertEquals(0, keepAlive.readVarInt()) + assertEquals(123_456, keepAlive.readVarInt()) + } + } + 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/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnectionTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnectionTest.kt new file mode 100644 index 0000000..a7ef87b --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnectionTest.kt @@ -0,0 +1,90 @@ +package pl.syntaxdevteam.craftconnect.protocol.modern + +import java.net.ServerSocket +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Test +import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile +import pl.syntaxdevteam.craftconnect.protocol.legacy.frame +import pl.syntaxdevteam.craftconnect.protocol.legacy.packet +import pl.syntaxdevteam.craftconnect.protocol.legacy.readPacket +import pl.syntaxdevteam.craftconnect.protocol.legacy.readProtocolString +import pl.syntaxdevteam.craftconnect.protocol.legacy.readVarInt +import pl.syntaxdevteam.craftconnect.protocol.legacy.writeProtocolString +import pl.syntaxdevteam.craftconnect.protocol.legacy.writeVarInt + +class ModernOfflineMinecraftConnectionTest { + @Test + fun completesLoginConfigurationAndPlayKeepAlive() = runBlocking { + ServerSocket(0).use { serverSocket -> + val server = async(Dispatchers.IO) { + serverSocket.accept().use { client -> + val input = client.getInputStream() + val output = client.getOutputStream() + readPacket(input, null).also { + assertEquals(0, it.readVarInt()) + assertEquals(775, it.readVarInt()) + it.readProtocolString() + it.readUnsignedShort() + assertEquals(2, it.readVarInt()) + } + val login = readPacket(input, null) + assertEquals(0, login.readVarInt()) + assertEquals("OfflineUser", login.readProtocolString()) + val uuid = UUID(login.readLong(), login.readLong()) + + output.write(frame(packet { + writeVarInt(2) + writeLong(uuid.mostSignificantBits) + writeLong(uuid.leastSignificantBits) + writeProtocolString("OfflineUser") + writeVarInt(0) + }, null)) + output.flush() + assertEquals(3, readPacket(input, null).readVarInt()) + assertEquals(0, readPacket(input, null).readVarInt()) + + output.write(frame(packet { + writeVarInt(0x0E) + writeVarInt(1) + writeProtocolString("minecraft") + writeProtocolString("core") + writeProtocolString("26.1") + }, null)) + output.flush() + val knownPacks = readPacket(input, null) + assertEquals(7, knownPacks.readVarInt()) + assertEquals(1, knownPacks.readVarInt()) + assertEquals("minecraft", knownPacks.readProtocolString()) + assertEquals("core", knownPacks.readProtocolString()) + assertEquals("26.1", knownPacks.readProtocolString()) + + output.write(frame(packet { writeVarInt(3) }, null)) + output.flush() + assertEquals(3, readPacket(input, null).readVarInt()) + + output.write(frame(packet { writeVarInt(0x2C); writeLong(987_654_321L) }, null)) + output.flush() + val keepAlive = readPacket(input, null) + assertEquals(0x1C, keepAlive.readVarInt()) + assertEquals(987_654_321L, keepAlive.readLong()) + } + } + val connection = ModernOfflineMinecraftConnection(2_000) + val profile = ServerProfile( + "test", "Test", "127.0.0.1:${serverSocket.localPort}", false, 0, 0, null, + ) + + val session = connection.connect(profile, "OfflineUser") + + assertEquals(775, session.protocolVersion) + assertEquals("OfflineUser", session.username) + withTimeout(2_000) { server.await() } + connection.disconnect() + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e93d44a..b644f64 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,14 +5,26 @@ 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 default adapter, `ModernOfflineMinecraftConnection`, implements offline-mode login +for Java protocol 775 (Minecraft 26.1), including the configuration state, compression, +keep-alive, position acknowledgement, chat and commands. The protocol 47 adapter remains +separate rather than mixing version-specific packet identifiers in one 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" }