Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CraftConnect">
<activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ fun CraftConnectApp(sessionManagerFactory: SessionManagerFactory) {
var activeServer by remember { mutableStateOf<ServerProfile?>(null) }
var sessionManager by remember { mutableStateOf<SessionManager?>(null) }
var connectionState by remember { mutableStateOf(ConnectionState.DISCONNECTED) }
var connectionError by remember { mutableStateOf<String?>(null) }
val coroutineScope = rememberCoroutineScope()

CraftBackground {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class LegacyOfflineMinecraftConnection(
})
val session = awaitLoginSuccess()
connectedSocket.soTimeout = 0
sendClientIdentity()
readerJob = scope.launch { playLoop() }
session
} catch (failure: MinecraftConnectionException) {
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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")
})
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Loading
Loading