core, ui: per-server roles for self-hosted servers (#7254)

* core, ui: plan per-server roles for self-hosted servers

* core: add per-server roles field to UserServer

* core: add nullable role columns to protocol_servers

* core: persist per-server roles

* core: validate server coverage using per-server roles

* test: cover per-server roles resolution and coverage

* multiplatform: per-server role toggles for self-hosted servers

* ios: per-server role toggles for self-hosted servers

* core: per-server role overrides with per-role defaults

* test: cover three-state per-server role resolution

* fix: derive Eq for ServerRolesOverride

* multiplatform: three-state role dropdowns on saved servers

* ios: three-state role pickers on saved servers

* test: per-server roles independent across two servers

* test: enable names role in name-resolution tests

* style: trim comments in per-server roles code

* chore: rename server_roles migration to 20260716 (last)

* core: per-server roles override operator roles, inherit when unset

* multiplatform: per-server role default inherits from operator

* ios: per-server role default inherits from operator

* refactor(servers): tidy per-server roles per review

- dedup no-operator default into ServerRoles.noOperatorDefault (Kotlin/Swift)
- iOS: move roles-section control flow to the call site via a named gate,
  and parse the server address once instead of up to three times
- Kotlin: collapse redundant derivedState; revert defaultOn->default rename
  for cross-platform parity
- drop unused Hashable conformance on Swift ServerRoles
- add agentServerCfgs test for names inheritance from an operator
- remove no-op enableNamesRole calls from dormant DirectoryTests
- fix ChatClient import ordering

* chore(migration): date server_roles migration 20260720

* only show roles when server is enabled, move section above QR code

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
sh
2026-07-20 16:35:13 +01:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent 966c9f7947
commit 2ea5940e81
19 changed files with 834 additions and 54 deletions
@@ -4647,6 +4647,18 @@ data class ServerRoles(
val storage: Boolean,
val proxy: Boolean,
val names: Boolean
) {
companion object {
// roles applied when a server matches no operator, mirrors core resolveServerRoles (Operators.hs)
val noOperatorDefault = ServerRoles(storage = true, proxy = true, names = false)
}
}
@Serializable
data class ServerRolesOverride(
val storage: Boolean? = null,
val proxy: Boolean? = null,
val names: Boolean? = null
)
@Serializable
@@ -4668,8 +4680,8 @@ data class UserOperatorServers(
serverDomains = emptyList(),
conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
smpRoles = ServerRoles.noOperatorDefault,
xftpRoles = ServerRoles.noOperatorDefault
)
companion object {
@@ -4801,7 +4813,8 @@ data class UserServer(
val preset: Boolean,
val tested: Boolean? = null,
val enabled: Boolean,
val deleted: Boolean
val deleted: Boolean,
val roles: ServerRolesOverride = ServerRolesOverride(),
) {
@Transient
private val createdAt: Date = Date()
@@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -88,6 +89,7 @@ fun ProtocolServerView(
ProtocolServerLayout(
draftServer,
serverProtocol,
userServers,
testing.value,
testServer = {
testing.value = true
@@ -120,6 +122,7 @@ fun ProtocolServerView(
private fun ProtocolServerLayout(
server: MutableState<UserServer>,
serverProtocol: ServerProtocol,
userServers: MutableState<List<UserOperatorServers>>,
testing: Boolean,
testServer: () -> Unit,
onDelete: () -> Unit,
@@ -130,7 +133,7 @@ private fun ProtocolServerLayout(
if (server.value.preset) {
PresetServer(server, testing, testServer)
} else {
CustomServer(server, testing, testServer, onDelete)
CustomServer(server, testing, testServer, onDelete, serverProtocol, userServers)
}
SectionBottomSpacer()
}
@@ -164,15 +167,11 @@ fun CustomServer(
testing: Boolean,
testServer: () -> Unit,
onDelete: (() -> Unit)?,
serverProtocol: ServerProtocol? = null,
userServers: MutableState<List<UserOperatorServers>>? = null
) {
val serverAddress = remember { mutableStateOf(server.value.server) }
val valid = remember {
derivedStateOf {
with(parseServerAddress(serverAddress.value)) {
this?.valid == true
}
}
}
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
SectionView(
stringResource(MR.strings.smp_servers_your_server_address),
icon = painterResource(MR.images.ic_error),
@@ -196,6 +195,12 @@ fun CustomServer(
UseServerSection(server, valid.value, testing, testServer, onDelete)
val op = remember(server.value.server) { serverProtocolAndOperator(server.value, userServers?.value ?: listOf())?.second }
if (serverProtocol == ServerProtocol.SMP && server.value.enabled && (!server.value.preset || server.value.roles != ServerRolesOverride())) {
SectionDividerSpaced()
ServerRolesSection(server, op?.smpRoles ?: ServerRoles.noOperatorDefault)
}
if (valid.value) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.smp_servers_add_to_another_device)) {
@@ -204,6 +209,38 @@ fun CustomServer(
}
}
@Composable
private fun ServerRolesSection(server: MutableState<UserServer>, inherited: ServerRoles) {
SectionView(stringResource(MR.strings.operator_use_for_messages)) {
RoleDropDown(stringResource(MR.strings.operator_use_for_messages_receiving), server.value.roles.storage, defaultOn = inherited.storage) {
server.value = server.value.copy(roles = server.value.roles.copy(storage = it))
}
RoleDropDown(stringResource(MR.strings.operator_use_for_messages_private_routing), server.value.roles.proxy, defaultOn = inherited.proxy) {
server.value = server.value.copy(roles = server.value.roles.copy(proxy = it))
}
RoleDropDown(stringResource(MR.strings.operator_use_for_names), server.value.roles.names, defaultOn = inherited.names) {
server.value = server.value.copy(roles = server.value.roles.copy(names = it))
}
}
}
@Composable
private fun RoleDropDown(title: String, value: Boolean?, defaultOn: Boolean, onSelected: (Boolean?) -> Unit) {
val values = remember(defaultOn, appPrefs.appLanguage.state.value) {
listOf(
null to String.format(generalGetString(MR.strings.chat_preferences_default), generalGetString(if (defaultOn) MR.strings.chat_preferences_yes else MR.strings.chat_preferences_no)),
true to generalGetString(MR.strings.chat_preferences_yes),
false to generalGetString(MR.strings.chat_preferences_no)
)
}
ExposedDropDownSettingRow(
title,
values,
rememberUpdatedState(value),
onSelected = onSelected
)
}
@Composable
private fun UseServerSection(
server: MutableState<UserServer>,