diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt index 7b760ff..d29629f 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt @@ -21,6 +21,7 @@ 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 @@ -28,12 +29,14 @@ 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, } @@ -41,6 +44,7 @@ enum class AppDestination { fun CraftConnectApp( sessionManagerFactory: SessionManagerFactory, serverRepository: ServerRepository, + accountRepository: AccountRepository, ) { var destination by remember { mutableStateOf(AppDestination.Servers) } var activeServer by remember { mutableStateOf(null) } @@ -49,6 +53,7 @@ fun CraftConnectApp( var connectionError by remember { mutableStateOf(null) } var serverDialog by remember { mutableStateOf(null) } val savedServers by serverRepository.servers.collectAsState() + val savedAccounts by accountRepository.accounts.collectAsState() val coroutineScope = rememberCoroutineScope() LaunchedEffect(sessionManager) { @@ -78,6 +83,7 @@ fun CraftConnectApp( servers = savedServers, connectionState = connectionState, connectionError = connectionError, + accounts = savedAccounts, onCreate = { name, address, favorite -> serverRepository.create(name, address, favorite) }, @@ -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() } } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt index 4abac75..8e959f3 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt @@ -5,6 +5,7 @@ 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 @@ -12,13 +13,16 @@ 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) } } } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/AccountListCodec.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/AccountListCodec.kt new file mode 100644 index 0000000..1f5a366 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/AccountListCodec.kt @@ -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): 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 = 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 +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepository.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepository.kt new file mode 100644 index 0000000..2c779c1 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepository.kt @@ -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> = 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) { + 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" } +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/account/AccountRepository.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/account/AccountRepository.kt new file mode 100644 index 0000000..3909704 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/account/AccountRepository.kt @@ -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> + + fun createOffline(username: String): AccountProfile + fun update(account: AccountProfile) + fun delete(id: String) +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/model/Models.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/model/Models.kt index 4f63393..60bf722 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/model/Models.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/model/Models.kt @@ -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, + 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 +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/server/ServerRepository.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/server/ServerRepository.kt new file mode 100644 index 0000000..969a4a7 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/server/ServerRepository.kt @@ -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> + + fun create(name: String, address: String, favorite: Boolean): ServerProfile + fun update(server: ServerProfile) + fun delete(id: String) +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/NbtCodec.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/NbtCodec.kt new file mode 100644 index 0000000..299241b --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/NbtCodec.kt @@ -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 + data class CompoundTag(val value: Map) : 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() + 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): 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 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 507a279..935bd1b 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 @@ -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 @@ -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) } } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/AccountsScreen.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/AccountsScreen.kt new file mode 100644 index 0000000..04b8729 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/AccountsScreen.kt @@ -0,0 +1,174 @@ +package pl.syntaxdevteam.craftconnect.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pl.syntaxdevteam.craftconnect.R +import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile +import pl.syntaxdevteam.craftconnect.ui.components.BrandHeader +import pl.syntaxdevteam.craftconnect.ui.components.GlowButton +import pl.syntaxdevteam.craftconnect.ui.components.NeonCard +import pl.syntaxdevteam.craftconnect.ui.theme.Crimson +import pl.syntaxdevteam.craftconnect.ui.theme.SurfaceRaised +import pl.syntaxdevteam.craftconnect.ui.theme.TextPrimary +import pl.syntaxdevteam.craftconnect.ui.theme.TextSecondary + +@Composable +fun AccountsScreen( + accounts: List, + onCreate: (String) -> Unit, + onUpdate: (AccountProfile) -> Unit, + onDelete: (String) -> Unit, +) { + var editorTarget by remember { mutableStateOf(null) } + var editorVisible by remember { mutableStateOf(false) } + var deleteTarget by remember { mutableStateOf(null) } + + Column(Modifier.fillMaxSize()) { + BrandHeader() + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.accounts), color = TextPrimary, fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.accounts_summary), color = TextSecondary, fontSize = 12.sp) + } + IconButton(onClick = { editorTarget = null; editorVisible = true }) { + Icon(Icons.Rounded.Add, stringResource(R.string.add_offline_account), tint = Crimson) + } + } + LazyColumn( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { MicrosoftAccountPreview() } + item { + Text( + stringResource(R.string.offline_accounts), + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) + } + if (accounts.isEmpty()) { + item { Text(stringResource(R.string.no_offline_accounts), color = TextSecondary) } + } + items(accounts, key = { it.id }) { account -> + OfflineAccountRow( + account, + onEdit = { editorTarget = account; editorVisible = true }, + onDelete = { deleteTarget = account }, + ) + } + } + } + + if (editorVisible) { + AccountEditorDialog( + account = editorTarget, + unavailableUsernames = accounts.filterNot { it.id == editorTarget?.id }.map { it.username }, + onDismiss = { editorVisible = false }, + ) { username -> + editorTarget?.let { onUpdate(it.copy(username = username)) } ?: onCreate(username) + editorVisible = false + } + } + deleteTarget?.let { account -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text(stringResource(R.string.delete_account)) }, + text = { Text(stringResource(R.string.delete_account_confirmation, account.username)) }, + confirmButton = { GlowButton(stringResource(R.string.delete)) { onDelete(account.id); deleteTarget = null } }, + dismissButton = { GlowButton(stringResource(R.string.cancel)) { deleteTarget = null } }, + containerColor = SurfaceRaised, + ) + } +} + +@Composable +private fun MicrosoftAccountPreview() { + NeonCard { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Lock, null, tint = Crimson, modifier = Modifier.size(34.dp)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.microsoft_accounts), fontWeight = FontWeight.Bold) + Text(stringResource(R.string.microsoft_accounts_coming_soon), color = TextSecondary, fontSize = 12.sp) + } + } + } +} + +@Composable +private fun OfflineAccountRow(account: AccountProfile, onEdit: () -> Unit, onDelete: () -> Unit) { + NeonCard { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Person, null, tint = Crimson, modifier = Modifier.size(34.dp)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(account.username, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.offline_account), color = TextSecondary, fontSize = 12.sp) + } + IconButton(onClick = onEdit) { Icon(Icons.Rounded.Edit, stringResource(R.string.edit_account), tint = TextSecondary) } + IconButton(onClick = onDelete) { Icon(Icons.Rounded.Delete, stringResource(R.string.delete_account), tint = TextSecondary) } + } + } +} + +@Composable +private fun AccountEditorDialog( + account: AccountProfile?, + unavailableUsernames: List, + onDismiss: () -> Unit, + onSave: (String) -> Unit, +) { + var username by remember(account) { mutableStateOf(account?.username.orEmpty()) } + var validationVisible by remember(account) { mutableStateOf(false) } + val valid = Regex("[A-Za-z0-9_]{3,16}").matches(username.trim()) + val duplicate = unavailableUsernames.any { it.equals(username.trim(), ignoreCase = true) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(if (account == null) R.string.add_offline_account else R.string.edit_account)) }, + text = { + Column { + OutlinedTextField(username, { username = it.take(16); validationVisible = false }, label = { Text(stringResource(R.string.offline_username)) }, singleLine = true) + if (validationVisible && !valid) Text(stringResource(R.string.invalid_offline_username), color = Crimson, fontSize = 12.sp) + if (validationVisible && duplicate) Text(stringResource(R.string.duplicate_offline_username), color = Crimson, fontSize = 12.sp) + } + }, + confirmButton = { GlowButton(stringResource(R.string.save)) { validationVisible = true; if (valid && !duplicate) onSave(username.trim()) } }, + dismissButton = { GlowButton(stringResource(R.string.cancel), onClick = onDismiss) }, + containerColor = SurfaceRaised, + ) +} 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 aaf8576..1afb67f 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import pl.syntaxdevteam.craftconnect.R import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile +import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile import pl.syntaxdevteam.craftconnect.domain.session.ConnectionState import pl.syntaxdevteam.craftconnect.ui.components.BrandHeader import pl.syntaxdevteam.craftconnect.ui.components.GlowButton @@ -57,6 +59,7 @@ fun ServersScreen( servers: List, connectionState: ConnectionState, connectionError: String?, + accounts: List, onCreate: (name: String, address: String, favorite: Boolean) -> Unit, onUpdate: (ServerProfile) -> Unit, onDelete: (String) -> Unit, @@ -160,6 +163,7 @@ fun ServersScreen( connecting = connectionState == ConnectionState.CONNECTING, failed = connectionState == ConnectionState.FAILED, connectionError = connectionError, + accounts = accounts, onDismiss = { connectionTarget = null }, onConnect = { address, username -> onConnect(target.copy(address = address), username) }, ) @@ -266,11 +270,12 @@ private fun OfflineConnectDialog( connecting: Boolean, failed: Boolean, connectionError: String?, + accounts: List, onDismiss: () -> Unit, onConnect: (String, String) -> Unit, ) { var address by remember(initialAddress) { mutableStateOf(initialAddress) } - var username by remember { mutableStateOf("") } + var username by remember(accounts) { mutableStateOf(accounts.firstOrNull()?.username.orEmpty()) } val valid = address.isNotBlank() && Regex("[A-Za-z0-9_]{3,16}").matches(username) AlertDialog( @@ -291,7 +296,18 @@ private fun OfflineConnectDialog( label = { Text(stringResource(R.string.offline_username)) }, singleLine = true, enabled = !connecting, + readOnly = true, ) + if (accounts.isEmpty()) { + Text(stringResource(R.string.account_required), color = Crimson, fontSize = 12.sp) + } else { + Text(stringResource(R.string.choose_saved_account), color = TextSecondary, fontSize = 12.sp) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(accounts, key = { it.id }) { account -> + GlowButton(account.username, onClick = { username = account.username }) + } + } + } Text(stringResource(R.string.modern_protocol_notice), color = TextSecondary, fontSize = 12.sp) if (failed) { Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb007df..a63269e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,22 @@ Chat Players Settings + Accounts + Account manager + Manage identities used to connect to servers. + Non-premium accounts + Non-premium · offline mode + Premium accounts (Microsoft) + Microsoft authentication will be available in a future release. + Add non-premium account + Edit account + Delete account + Delete account “%1$s”? + No non-premium accounts yet. Use + to add one. + Use 3–16 letters, digits or underscores. + An account with this username already exists. + Add a non-premium account in Account manager before connecting. + Saved account: Search servers… Search players… Add server diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepositoryTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepositoryTest.kt new file mode 100644 index 0000000..3fdc769 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepositoryTest.kt @@ -0,0 +1,40 @@ +package pl.syntaxdevteam.craftconnect.data.account + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SharedPreferencesAccountRepositoryTest { + @Test + fun `creates updates deletes and persists offline accounts`() { + val storage = MemoryStorage() + val repository = SharedPreferencesAccountRepository(storage) { "account-id" } + + val created = repository.createOffline(" Steve_123 ") + assertEquals("Steve_123", created.username) + repository.update(created.copy(username = "Alex")) + assertEquals("Alex", repository.accounts.value.single().username) + assertEquals("Alex", SharedPreferencesAccountRepository(storage).accounts.value.single().username) + + repository.delete(created.id) + assertTrue(repository.accounts.value.isEmpty()) + } + + @Test(expected = IllegalArgumentException::class) + fun `rejects invalid offline username`() { + SharedPreferencesAccountRepository(MemoryStorage()).createOffline("a!") + } + + @Test(expected = IllegalArgumentException::class) + fun `rejects duplicate usernames ignoring case`() { + val repository = SharedPreferencesAccountRepository(MemoryStorage()) + repository.createOffline("Steve") + repository.createOffline("steve") + } + + private class MemoryStorage : AccountStorage { + private var value: String? = null + override fun read(): String? = value + override fun write(value: String) { this.value = value } + } +}