diff --git a/README.md b/README.md index 699b0ec..14611f9 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Implemented now: - Kotlin + Jetpack Compose Android project, - dark crimson / neon design system, +- branded launcher, adaptive and in-app logo assets, - saved server browser, -- direct offline-mode connection using Minecraft Java 1.8.x protocol 47, +- direct offline-mode connection using Minecraft Java 26.1 protocol 775, - chat and command surface, - online player list, - settings screen, @@ -24,10 +25,10 @@ Implemented now: - GitHub Actions Android CI, - repository guidance in `AGENTS.md`. -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 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 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 { @@ -55,13 +56,16 @@ fun CraftConnectApp(sessionManagerFactory: SessionManagerFactory) { when (destination) { AppDestination.Servers -> ServersScreen( 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 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 index d6255d5..dafad30 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt @@ -105,7 +105,7 @@ class DefaultSessionManager( } 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) + 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 index 41d1dc2..a3dc77c 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerFactory.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerFactory.kt @@ -2,8 +2,8 @@ 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 +import pl.syntaxdevteam.craftconnect.protocol.modern.ModernOfflineMinecraftConnection class DefaultSessionManagerFactory : SessionManagerFactory { - override fun create(): SessionManager = DefaultSessionManager(LegacyOfflineMinecraftConnection()) + override fun create(): SessionManager = DefaultSessionManager(ModernOfflineMinecraftConnection()) } 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 index 1c080f7..c19a711 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionModels.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionModels.kt @@ -25,11 +25,27 @@ data class SessionSnapshot( sealed interface SessionError { val diagnosticCode: String + val serverMessage: 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 + 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 { 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 ef1d34e..b4df817 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt @@ -21,9 +21,11 @@ interface MinecraftConnection { 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) - class Authentication(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause) - class Protocol(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, 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 index ca0fa90..c6d1633 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnection.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnection.kt @@ -57,6 +57,7 @@ class LegacyOfflineMinecraftConnection( }) val session = awaitLoginSuccess() connectedSocket.soTimeout = 0 + sendClientIdentity() readerJob = scope.launch { playLoop() } session } catch (failure: MinecraftConnectionException) { @@ -102,8 +103,8 @@ class LegacyOfflineMinecraftConnection( val packetInput = readPacket(requireInput(), compressionThreshold) when (val packetId = packetInput.readVarInt()) { LOGIN_DISCONNECT_PACKET -> { - packetInput.readProtocolString(MAX_JSON_LENGTH) - throw MinecraftConnectionException.Authentication("login_rejected") + val reason = packetInput.readProtocolString(MAX_JSON_LENGTH).minecraftText() + throw MinecraftConnectionException.Authentication("login_rejected", reason) } LOGIN_SUCCESS_PACKET -> return ConnectedSession( protocolVersion = PROTOCOL_VERSION, @@ -124,7 +125,7 @@ class LegacyOfflineMinecraftConnection( while (open.get()) { val packetInput = readPacket(requireInput(), compressionThreshold) when (packetInput.readVarInt()) { - CLIENTBOUND_KEEP_ALIVE_PACKET -> respondToKeepAlive(packetInput.readInt()) + CLIENTBOUND_KEEP_ALIVE_PACKET -> respondToKeepAlive(packetInput.readVarInt()) CLIENTBOUND_POSITION_PACKET -> respondToPosition(packetInput) CLIENTBOUND_DISCONNECT_PACKET -> { packetInput.readProtocolString(MAX_JSON_LENGTH) @@ -140,7 +141,23 @@ class LegacyOfflineMinecraftConnection( private fun respondToKeepAlive(id: Int) { sendPacket(packet { writeVarInt(SERVERBOUND_KEEP_ALIVE_PACKET) - writeInt(id) + 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") }) } @@ -237,6 +254,8 @@ class LegacyOfflineMinecraftConnection( 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 @@ -245,3 +264,13 @@ class LegacyOfflineMinecraftConnection( 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/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/ServersScreen.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt index aec532c..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 @@ -52,6 +52,7 @@ import pl.syntaxdevteam.craftconnect.ui.theme.TextSecondary @Composable fun ServersScreen( connectionState: ConnectionState, + connectionError: String?, onConnect: (ServerProfile, String) -> Unit, ) { var query by remember { mutableStateOf("") } @@ -125,6 +126,7 @@ fun ServersScreen( initialAddress = target?.address.orEmpty(), connecting = connectionState == ConnectionState.CONNECTING, failed = connectionState == ConnectionState.FAILED, + connectionError = connectionError, onDismiss = { connectionTarget = null customConnection = false @@ -150,6 +152,7 @@ private fun OfflineConnectDialog( initialAddress: String, connecting: Boolean, failed: Boolean, + connectionError: String?, onDismiss: () -> Unit, onConnect: (String, String) -> Unit, ) { @@ -176,8 +179,14 @@ private fun OfflineConnectDialog( 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) + 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 = { diff --git a/app/src/main/res/drawable-nodpi/craftconnect_logo.png b/app/src/main/res/drawable-nodpi/craftconnect_logo.png new file mode 100644 index 0000000..2811fc5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/craftconnect_logo.png differ 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 014acac..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 @@ -16,7 +17,7 @@ Server address Offline username Cancel - Uses Minecraft Java 1.8.x protocol (47). A newer server requires ViaVersion or equivalent compatibility. + 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 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 index 7da2386..b2a5de6 100644 --- a/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt @@ -50,6 +50,22 @@ class DefaultSessionManagerTest { ) } + @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()) 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 index 9b8eea6..3c9a83b 100644 --- a/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnectionTest.kt +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/legacy/LegacyOfflineMinecraftConnectionTest.kt @@ -35,14 +35,22 @@ class LegacyOfflineMinecraftConnectionTest { }, 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) - writeInt(123_456) + writeVarInt(123_456) }, null)) output.flush() val keepAlive = readPacket(input, null) assertEquals(0, keepAlive.readVarInt()) - assertEquals(123_456, keepAlive.readInt()) + assertEquals(123_456, keepAlive.readVarInt()) } } val connection = LegacyOfflineMinecraftConnection(connectTimeoutMillis = 2_000) 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 07a738e..b644f64 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,12 +20,10 @@ 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 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: