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
12 changes: 12 additions & 0 deletions app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,30 @@ 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.domain.server.ServerRepository
import pl.syntaxdevteam.craftconnect.domain.account.AccountRepository
import pl.syntaxdevteam.craftconnect.ui.components.CraftBackground
import pl.syntaxdevteam.craftconnect.ui.components.CraftBottomBar
import pl.syntaxdevteam.craftconnect.ui.components.ServerDialogForm
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 pl.syntaxdevteam.craftconnect.ui.screens.AccountsScreen
import kotlinx.coroutines.launch

enum class AppDestination {
Servers,
Chat,
Players,
Accounts,
Settings,
}

@Composable
fun CraftConnectApp(
sessionManagerFactory: SessionManagerFactory,
serverRepository: ServerRepository,
accountRepository: AccountRepository,
) {
var destination by remember { mutableStateOf(AppDestination.Servers) }
var activeServer by remember { mutableStateOf<ServerProfile?>(null) }
Expand All @@ -49,6 +53,7 @@ fun CraftConnectApp(
var connectionError by remember { mutableStateOf<String?>(null) }
var serverDialog by remember { mutableStateOf<ServerDialogRequest?>(null) }
val savedServers by serverRepository.servers.collectAsState()
val savedAccounts by accountRepository.accounts.collectAsState()
val coroutineScope = rememberCoroutineScope()

LaunchedEffect(sessionManager) {
Expand Down Expand Up @@ -78,6 +83,7 @@ fun CraftConnectApp(
servers = savedServers,
connectionState = connectionState,
connectionError = connectionError,
accounts = savedAccounts,
onCreate = { name, address, favorite ->
serverRepository.create(name, address, favorite)
},
Expand Down Expand Up @@ -106,6 +112,12 @@ fun CraftConnectApp(
}
}
AppDestination.Players -> PlayersScreen(currentServer)
AppDestination.Accounts -> AccountsScreen(
accounts = savedAccounts,
onCreate = accountRepository::createOffline,
onUpdate = accountRepository::update,
onDelete = accountRepository::delete,
)
AppDestination.Settings -> SettingsScreen()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import org.xbill.DNS.config.AndroidResolverConfigProvider
import pl.syntaxdevteam.craftconnect.data.server.SharedPreferencesServerRepository
import pl.syntaxdevteam.craftconnect.data.account.SharedPreferencesAccountRepository
import pl.syntaxdevteam.craftconnect.data.session.DefaultSessionManagerFactory
import pl.syntaxdevteam.craftconnect.ui.theme.CraftConnectTheme

class MainActivity : ComponentActivity() {
private val serverRepository by lazy {
SharedPreferencesServerRepository(getSharedPreferences("saved_servers", MODE_PRIVATE))
}
private val accountRepository by lazy {
SharedPreferencesAccountRepository(getSharedPreferences("saved_accounts", MODE_PRIVATE))
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AndroidResolverConfigProvider.setContext(applicationContext)
setContent {
CraftConnectTheme {
CraftConnectApp(DefaultSessionManagerFactory(), serverRepository)
CraftConnectApp(DefaultSessionManagerFactory(), serverRepository, accountRepository)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package pl.syntaxdevteam.craftconnect.data.account

import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.util.Base64
import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile
import pl.syntaxdevteam.craftconnect.domain.model.AccountType

internal object AccountListCodec {
fun encode(accounts: List<AccountProfile>): String {
val bytes = ByteArrayOutputStream().use { buffer ->
DataOutputStream(buffer).use { output ->
output.writeInt(FORMAT_VERSION)
output.writeInt(accounts.size)
accounts.forEach { account ->
output.writeUTF(account.id)
output.writeUTF(account.username)
output.writeUTF(account.type.name)
}
}
buffer.toByteArray()
}
return Base64.getEncoder().encodeToString(bytes)
}

fun decode(encoded: String): List<AccountProfile> = runCatching {
DataInputStream(ByteArrayInputStream(Base64.getDecoder().decode(encoded))).use { input ->
require(input.readInt() == FORMAT_VERSION)
val count = input.readInt()
require(count in 0..MAX_ACCOUNTS)
List(count) {
AccountProfile(input.readUTF(), input.readUTF(), AccountType.valueOf(input.readUTF()))
}
}
}.getOrDefault(emptyList())

private const val FORMAT_VERSION = 1
private const val MAX_ACCOUNTS = 100
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package pl.syntaxdevteam.craftconnect.data.account

import android.content.SharedPreferences
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pl.syntaxdevteam.craftconnect.domain.account.AccountRepository
import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile
import pl.syntaxdevteam.craftconnect.domain.model.AccountType

class SharedPreferencesAccountRepository internal constructor(
private val storage: AccountStorage,
private val createId: () -> String = { UUID.randomUUID().toString() },
) : AccountRepository {
constructor(preferences: SharedPreferences) : this(SharedPreferencesAccountStorage(preferences))

private val mutableAccounts = MutableStateFlow(AccountListCodec.decode(storage.read().orEmpty()))
override val accounts: StateFlow<List<AccountProfile>> = mutableAccounts.asStateFlow()

@Synchronized
override fun createOffline(username: String): AccountProfile {
val normalized = validateUsername(username)
require(mutableAccounts.value.none { it.username.equals(normalized, ignoreCase = true) }) {
"Account username already exists"
}
return AccountProfile(createId(), normalized, AccountType.OFFLINE).also {
publish(mutableAccounts.value + it)
}
}

@Synchronized
override fun update(account: AccountProfile) {
require(account.type == AccountType.OFFLINE) { "Microsoft accounts cannot be edited yet" }
val normalized = account.copy(username = validateUsername(account.username))
require(mutableAccounts.value.none {
it.id != normalized.id && it.username.equals(normalized.username, ignoreCase = true)
}) { "Account username already exists" }
if (mutableAccounts.value.none { it.id == normalized.id }) return
publish(mutableAccounts.value.map { if (it.id == normalized.id) normalized else it })
}

@Synchronized
override fun delete(id: String) {
val updated = mutableAccounts.value.filterNot { it.id == id }
if (updated.size != mutableAccounts.value.size) publish(updated)
}

private fun publish(accounts: List<AccountProfile>) {
storage.write(AccountListCodec.encode(accounts))
mutableAccounts.value = accounts
}

private fun validateUsername(username: String): String = username.trim().also {
require(USERNAME.matches(it)) { "Username must contain 3-16 letters, digits or underscores" }
}

private companion object {
val USERNAME = Regex("[A-Za-z0-9_]{3,16}")
}
}

internal interface AccountStorage {
fun read(): String?
fun write(value: String)
}

private class SharedPreferencesAccountStorage(private val preferences: SharedPreferences) : AccountStorage {
override fun read(): String? = preferences.getString(KEY, null)
override fun write(value: String) { preferences.edit().putString(KEY, value).apply() }

private companion object { const val KEY = "accounts" }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package pl.syntaxdevteam.craftconnect.domain.account

import kotlinx.coroutines.flow.StateFlow
import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile

interface AccountRepository {
val accounts: StateFlow<List<AccountProfile>>

fun createOffline(username: String): AccountProfile
fun update(account: AccountProfile)
fun delete(id: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,36 @@ data class PlayerInfo(
val role: PlayerRole,
val pingMs: Int,
)

enum class AccountType {
OFFLINE,
MICROSOFT,
}

data class AccountProfile(
val id: String,
val username: String,
val type: AccountType,
)

data class ServerDialogField(
val key: String,
val label: String,
val maxLength: Int,
val secret: Boolean,
)

data class ServerDialogRequest(
val title: String,
val message: String?,
val fields: List<ServerDialogField>,
val submitActionId: String,
val submitLabel: String,
val cancelActionId: String?,
val cancelLabel: String?,
)

sealed interface ServerDialogEvent {
data class Show(val dialog: ServerDialogRequest) : ServerDialogEvent
data object Clear : ServerDialogEvent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package pl.syntaxdevteam.craftconnect.domain.server

import kotlinx.coroutines.flow.StateFlow
import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile

interface ServerRepository {
val servers: StateFlow<List<ServerProfile>>

fun create(name: String, address: String, favorite: Boolean): ServerProfile
fun update(server: ServerProfile)
fun delete(id: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package pl.syntaxdevteam.craftconnect.protocol.modern

import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import pl.syntaxdevteam.craftconnect.protocol.legacy.writeVarInt

internal sealed interface NbtTag {
data class ByteTag(val value: Byte) : NbtTag
data class ShortTag(val value: Short) : NbtTag
data class IntTag(val value: Int) : NbtTag
data class LongTag(val value: Long) : NbtTag
data class FloatTag(val value: Float) : NbtTag
data class DoubleTag(val value: Double) : NbtTag
data class ByteArrayTag(val value: ByteArray) : NbtTag
data class StringTag(val value: String) : NbtTag
data class ListTag(val value: List<NbtTag>) : NbtTag
data class CompoundTag(val value: Map<String, NbtTag>) : NbtTag
data class IntArrayTag(val value: IntArray) : NbtTag
data class LongArrayTag(val value: LongArray) : NbtTag
}

internal fun DataInputStream.readInlineNbtCompound(): NbtTag.CompoundTag {
require(readUnsignedByte() == COMPOUND) { "NBT root must be a compound" }
return readPayload(COMPOUND) as NbtTag.CompoundTag
}

private fun DataInputStream.readPayload(type: Int): NbtTag = when (type) {
BYTE -> NbtTag.ByteTag(readByte())
SHORT -> NbtTag.ShortTag(readShort())
INT -> NbtTag.IntTag(readInt())
LONG -> NbtTag.LongTag(readLong())
FLOAT -> NbtTag.FloatTag(readFloat())
DOUBLE -> NbtTag.DoubleTag(readDouble())
BYTE_ARRAY -> NbtTag.ByteArrayTag(ByteArray(readSafeLength()).also(::readFully))
STRING -> NbtTag.StringTag(readNbtString())
LIST -> {
val elementType = readUnsignedByte()
val size = readSafeLength()
NbtTag.ListTag(List(size) { readPayload(elementType) })
}
COMPOUND -> {
val values = linkedMapOf<String, NbtTag>()
while (true) {
val childType = readUnsignedByte()
if (childType == END) break
values[readNbtString()] = readPayload(childType)
}
NbtTag.CompoundTag(values)
}
INT_ARRAY -> NbtTag.IntArrayTag(IntArray(readSafeLength()) { readInt() })
LONG_ARRAY -> NbtTag.LongArrayTag(LongArray(readSafeLength()) { readLong() })
else -> error("Unsupported NBT tag type $type")
}

private fun DataInputStream.readNbtString(): String {
val size = readUnsignedShort()
return ByteArray(size).also(::readFully).toString(Charsets.UTF_8)
}

private fun DataInputStream.readSafeLength(): Int = readInt().also {
require(it in 0..MAX_COLLECTION_SIZE) { "Invalid NBT collection size" }
}

internal fun encodeLengthPrefixedStringCompound(values: Map<String, String>): ByteArray {
val nbt = ByteArrayOutputStream().use { bytes ->
DataOutputStream(bytes).use { output ->
output.writeByte(COMPOUND)
values.forEach { (key, value) ->
output.writeByte(STRING)
output.writeNbtString(key)
output.writeNbtString(value)
}
output.writeByte(END)
}
bytes.toByteArray()
}
return ByteArrayOutputStream().use { bytes ->
DataOutputStream(bytes).use { output ->
output.writeVarInt(nbt.size)
output.write(nbt)
}
bytes.toByteArray()
}
}

private fun DataOutputStream.writeNbtString(value: String) {
val bytes = value.toByteArray(Charsets.UTF_8)
require(bytes.size <= 65_535)
writeShort(bytes.size)
write(bytes)
}

private const val END = 0
private const val BYTE = 1
private const val SHORT = 2
private const val INT = 3
private const val LONG = 4
private const val FLOAT = 5
private const val DOUBLE = 6
private const val BYTE_ARRAY = 7
private const val STRING = 8
private const val LIST = 9
private const val COMPOUND = 10
private const val INT_ARRAY = 11
private const val LONG_ARRAY = 12
private const val MAX_COLLECTION_SIZE = 65_536
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Chat
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
Expand Down Expand Up @@ -257,6 +258,7 @@ fun CraftBottomBar(destination: AppDestination, onDestination: (AppDestination)
BottomItem(AppDestination.Servers, stringResource(R.string.nav_servers), Icons.Rounded.Home, destination, onDestination)
BottomItem(AppDestination.Chat, stringResource(R.string.nav_chat), Icons.AutoMirrored.Rounded.Chat, destination, onDestination)
BottomItem(AppDestination.Players, stringResource(R.string.nav_players), Icons.Rounded.Person, destination, onDestination)
BottomItem(AppDestination.Accounts, stringResource(R.string.nav_accounts), Icons.Rounded.AccountCircle, destination, onDestination)
BottomItem(AppDestination.Settings, stringResource(R.string.nav_settings), Icons.Rounded.Settings, destination, onDestination)
}
}
Expand Down
Loading
Loading