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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,26 @@ Implemented now:
- Kotlin + Jetpack Compose Android project,
- dark crimson / neon design system,
- saved server browser,
- mock connect flow,
- direct offline-mode connection using Minecraft Java 1.8.x protocol 47,
- chat and command surface,
- online player list,
- settings screen,
- reusable UI components,
- observable session lifecycle and typed diagnostic errors,
- protocol abstraction ready for a real implementation,
- Gradle Wrapper,
- GitHub Actions Android CI,
- repository guidance in `AGENTS.md`.

Networking is intentionally not implemented yet. The current interface uses demo data.
The first networking slice supports direct TCP login to an offline-mode server using
Minecraft Java 1.8.x protocol 47, compression negotiation, keep-alive replies and the
initial position acknowledgement. Modern servers need a compatibility layer such as
ViaVersion until a current protocol adapter is implemented.

The client sends only the selected nickname. In offline mode the server or proxy owns
identity assignment and returns the UUID during login; CraftConnect does not allow an
arbitrary UUID to be injected. Permission data tied to an offline UUID is only as secure
as the server's nickname protection, so this mode must not be exposed as authentication.

## Toolchain

Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
33 changes: 28 additions & 5 deletions app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pl.syntaxdevteam.craftconnect.data.DemoRepository
import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile
import pl.syntaxdevteam.craftconnect.domain.session.ConnectionState
import pl.syntaxdevteam.craftconnect.domain.session.SessionManager
import pl.syntaxdevteam.craftconnect.domain.session.SessionManagerFactory
import pl.syntaxdevteam.craftconnect.ui.components.CraftBackground
import pl.syntaxdevteam.craftconnect.ui.components.CraftBottomBar
import pl.syntaxdevteam.craftconnect.ui.screens.ChatScreen
import pl.syntaxdevteam.craftconnect.ui.screens.PlayersScreen
import pl.syntaxdevteam.craftconnect.ui.screens.ServersScreen
import pl.syntaxdevteam.craftconnect.ui.screens.SettingsScreen
import kotlinx.coroutines.launch

enum class AppDestination {
Servers,
Expand All @@ -27,9 +32,12 @@ enum class AppDestination {
}

@Composable
fun CraftConnectApp() {
fun CraftConnectApp(sessionManagerFactory: SessionManagerFactory) {
var destination by remember { mutableStateOf(AppDestination.Servers) }
var activeServer by remember { mutableStateOf<ServerProfile?>(null) }
var sessionManager by remember { mutableStateOf<SessionManager?>(null) }
var connectionState by remember { mutableStateOf(ConnectionState.DISCONNECTED) }
val coroutineScope = rememberCoroutineScope()

CraftBackground {
Scaffold(
Expand All @@ -46,12 +54,27 @@ fun CraftConnectApp() {
Box(Modifier.padding(contentPadding)) {
when (destination) {
AppDestination.Servers -> ServersScreen(
onConnect = { server ->
activeServer = server
destination = AppDestination.Chat
connectionState = connectionState,
onConnect = { server, username ->
coroutineScope.launch {
val manager = sessionManagerFactory.create()
sessionManager = manager
connectionState = ConnectionState.CONNECTING
manager.connect(server, username)
connectionState = manager.session.value.connectionState
if (connectionState == ConnectionState.CONNECTED) {
activeServer = server.copy(online = true)
destination = AppDestination.Chat
}
}
},
)
AppDestination.Chat -> ChatScreen(currentServer)
AppDestination.Chat -> ChatScreen(currentServer) { message ->
coroutineScope.launch {
val manager = sessionManager ?: return@launch
if (message.startsWith('/')) manager.sendCommand(message) else manager.sendChat(message)
}
}
AppDestination.Players -> PlayersScreen(currentServer)
AppDestination.Settings -> SettingsScreen()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ 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() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CraftConnectTheme {
CraftConnectApp()
CraftConnectApp(DefaultSessionManagerFactory())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SessionEvent>(extraBufferCapacity = 16)

override val session: StateFlow<SessionSnapshot> = mutableSession.asStateFlow()
override val events: SharedFlow<SessionEvent> = mutableEvents.asSharedFlow()

override suspend fun connect(server: ServerProfile, username: String) = operationMutex.withLock {
if (session.value.connectionState != ConnectionState.DISCONNECTED &&
session.value.connectionState != ConnectionState.FAILED
) {
return@withLock
}

update(SessionSnapshot(connectionState = ConnectionState.CONNECTING, server = server))
try {
val connected = connection.connect(server, username)
update(
session.value.copy(
connectionState = ConnectionState.CONNECTED,
protocolVersion = connected.protocolVersion,
username = connected.username,
uuid = connected.uuid,
connectedAtEpochMillis = clock(),
lastError = null,
),
)
} catch (cancelled: CancellationException) {
update(SessionSnapshot())
throw cancelled
} catch (failure: MinecraftConnectionException) {
fail(failure.toSessionError())
} catch (_: Exception) {
fail(SessionError.Protocol("unexpected_connection_failure"))
}
}

override suspend fun disconnect() = operationMutex.withLock {
if (session.value.connectionState == ConnectionState.DISCONNECTED) return@withLock

var error: SessionError? = null
try {
connection.disconnect()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
error = SessionError.Disconnected("disconnect_cleanup_failure")
}
update(SessionSnapshot(lastError = error))
mutableEvents.emit(SessionEvent.ConnectionClosed(error))
}

override suspend fun sendChat(message: String) {
requireConnected()
connection.sendChat(message)
}

override suspend fun sendCommand(command: String) {
requireConnected()
connection.sendCommand(command)
}

private fun requireConnected() {
check(session.value.connectionState == ConnectionState.CONNECTED) {
"A Minecraft session must be connected before sending data"
}
}

private suspend fun fail(error: SessionError) {
update(session.value.copy(connectionState = ConnectionState.FAILED, lastError = error))
mutableEvents.emit(SessionEvent.ConnectionFailed(error))
}

private suspend fun update(snapshot: SessionSnapshot) {
val previous = mutableSession.value.connectionState
mutableSession.value = snapshot
if (previous != snapshot.connectionState) {
mutableEvents.emit(SessionEvent.StateChanged(previous, snapshot.connectionState))
}
}
}

private fun MinecraftConnectionException.toSessionError(): SessionError = when (this) {
is MinecraftConnectionException.Network -> SessionError.Network(diagnosticCode)
is MinecraftConnectionException.Authentication -> SessionError.Authentication(diagnosticCode)
is MinecraftConnectionException.Protocol -> SessionError.Protocol(diagnosticCode)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package pl.syntaxdevteam.craftconnect.data.session

import pl.syntaxdevteam.craftconnect.domain.session.SessionManager
import pl.syntaxdevteam.craftconnect.domain.session.SessionManagerFactory
import pl.syntaxdevteam.craftconnect.protocol.legacy.LegacyOfflineMinecraftConnection

class DefaultSessionManagerFactory : SessionManagerFactory {
override fun create(): SessionManager = DefaultSessionManager(LegacyOfflineMinecraftConnection())
}
Original file line number Diff line number Diff line change
@@ -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<SessionSnapshot>
val events: SharedFlow<SessionEvent>

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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package pl.syntaxdevteam.craftconnect.domain.session

import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile

enum class ConnectionState {
DISCONNECTED,
CONNECTING,
AUTHENTICATING,
JOINING,
CONNECTED,
RECONNECTING,
FAILED,
}

data class SessionSnapshot(
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
val server: ServerProfile? = null,
val protocolVersion: Int? = null,
val username: String? = null,
val uuid: String? = null,
val connectedAtEpochMillis: Long? = null,
val reconnectCount: Int = 0,
val lastError: SessionError? = null,
)

sealed interface SessionError {
val diagnosticCode: String

data class Network(override val diagnosticCode: String) : SessionError
data class Authentication(override val diagnosticCode: String) : SessionError
data class Protocol(override val diagnosticCode: String) : SessionError
data class Disconnected(override val diagnosticCode: String) : SessionError
}

sealed interface SessionEvent {
data class StateChanged(
val previous: ConnectionState,
val current: ConnectionState,
) : SessionEvent

data class ConnectionFailed(val error: SessionError) : SessionEvent
data class ConnectionClosed(val error: SessionError?) : SessionEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,28 @@ package pl.syntaxdevteam.craftconnect.protocol

import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile

sealed interface ConnectionState {
data object Disconnected : ConnectionState
data class Connecting(val server: ServerProfile) : ConnectionState
data class Connected(val server: ServerProfile, val protocolVersion: Int) : ConnectionState
data class Failed(val reason: String) : ConnectionState
}
data class ConnectedSession(
val protocolVersion: Int,
val username: String,
val uuid: String,
)

/**
* Stable boundary between Android presentation code and the future headless
* Minecraft protocol implementation.
*
* Compose screens must never depend directly on packet classes.
* Packet-independent boundary implemented by a version-aware protocol adapter.
* Presentation code communicates through SessionManager instead of this type.
*/
interface MinecraftConnection {
val state: ConnectionState

suspend fun connect(server: ServerProfile)
suspend fun connect(server: ServerProfile, username: String): ConnectedSession
suspend fun disconnect()
suspend fun sendChat(message: String)
suspend fun sendCommand(command: String)
}

sealed class MinecraftConnectionException(
val diagnosticCode: String,
cause: Throwable? = null,
) : Exception(diagnosticCode, cause) {
class Network(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause)
class Authentication(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause)
class Protocol(code: String, cause: Throwable? = null) : MinecraftConnectionException(code, cause)
}
Loading
Loading