android, desktop, ios: connect via SimpleX name (#7068)

* android, desktop, ios: connect via SimpleX name

* android, desktop, ios: open known contact on name lookup; surface prepared contact

Name search opens the contact (not list-filter); resolved/prepared contacts and groups are added to the chat list so they're visible and openable. Kotlin compile-verified; iOS edits pattern-matched, pending Xcode build.

* feat(names): UI names role + agent NAME error

Parity with the core names rework (#7045):

- Add `names` to ServerRoles (Android + iOS) and a per-operator
  "To resolve names" toggle under the SMP section (xftp has no names
  role; the shared ServerRoles field stays false there).
- Mirror the new agent error: NameErrorType + a NAME case on both
  AgentErrorType and ProtocolErrorType (the SMP ErrorType mirror), so
  the new SMP/agent NAME errors decode instead of crashing the decoder.
- Remove ChatErrorType.SimplexNameResolverUnavailable (deleted in core)
  and repoint its "name resolution unavailable" alert to the agent
  NAME NO_SERVERS error, reusing the existing strings.

Android (multiplatform) compiles clean; iOS mirrors the same changes
(builds in Xcode).

* feat(names): UI warning when no server resolves names

Mirror core USWNoNamesServers: add the NoNamesServers variant to
UserServersWarning (Kotlin sealed class + Swift enum) and its
globalWarning / globalServersWarning branch, rendered by the existing
ServersWarningFooter / ServersWarningView. Matches the noChatRelays
warning exactly.

* fix(servers): show all validation errors and warnings, not just the first

globalServersError/Warning returned only the first entry, so a second
warning (e.g. no names servers behind no chat relays) or a second error
(e.g. no XFTP servers behind no SMP servers) was never displayed. Make
them return all entries (globalServersErrors/Warnings) and render one
footer row each, across the three combined-footer views. Per-protocol
SMP/XFTP footers are unchanged.

* docs(names): add SimpleX name UI plan

* feat(names): add name model fields + SimplexName helpers

* feat(names): verify + set-name API & responses

* docs(names): bump core sync to 5008b4e62

* feat(names): show name + verification on chat info

* feat(names): add Verify SimpleX names privacy toggle

* feat(names): add set-name screens (user + channel)

* update ui

* fix kotlin

* fix codable

* fix ios

* fix errors

* api in UI

* send name as string in protocol

* update simplexmq, capitalize

* verify that name is in profile for own and known contacts and channels as condition of name resolution

* update simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
sh
2026-06-30 11:26:04 +01:00
committed by GitHub
co-authored by Evgeny Poberezkin Evgeny @ SimpleX Chat
parent ec8fe669c7
commit 9bd38c4aec
46 changed files with 1371 additions and 207 deletions
@@ -2068,7 +2068,9 @@ data class LocalProfile(
val contactLink: String? = null,
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null,
val localBadge: LocalBadge? = null
val localBadge: LocalBadge? = null,
val simplexName: SimplexNameClaim? = null,
val contactDomainVerification: Boolean? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
@@ -2198,6 +2200,7 @@ data class GroupInfo (
val chatTags: List<Long>,
val chatItemTTL: Long?,
override val localAlias: String,
val groupDomainVerification: Boolean? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
@@ -2319,10 +2322,18 @@ object GroupTypeSerializer : KSerializer<GroupType> {
}
}
@Serializable
data class SimplexNameClaim(
val name: String,
val proof: NameClaimProof? = null
) {
val shortName: String get() = name.removePrefix("simplex:/name")
}
@Serializable
data class PublicGroupAccess(
val groupWebPage: String? = null,
val groupDomain: String? = null,
val simplexName: SimplexNameClaim? = null,
val domainWebPage: Boolean = false,
val allowEmbedding: Boolean = false
)
@@ -4875,14 +4886,35 @@ enum class SimplexLinkType(val linkType: String) {
data class SimplexNameInfo(
val nameType: SimplexNameType,
val nameDomain: SimplexNameDomain
)
) {
// prefix-less domain for prefilling the set-name field (shortName without the @/# prefix)
val editDomain: String
get() = if (nameType == SimplexNameType.publicGroup && nameDomain.nameTLD == SimplexTLD.simplex && nameDomain.subDomain.isEmpty())
nameDomain.domain
else nameDomain.fullDomainName
// user-facing display string, mirrors backend shortNameInfoStr
val shortName: String
get() = (if (nameType == SimplexNameType.publicGroup) "#" else "@") + editDomain
}
@Serializable
data class SimplexNameDomain(
val nameTLD: SimplexTLD,
val domain: String,
val subDomain: List<String>
)
) {
// mirrors backend fullDomainName: reverse(subDomain) + [domain] + tld
val fullDomainName: String
get() {
val tld = when (nameTLD) {
SimplexTLD.simplex -> listOf("simplex")
SimplexTLD.testing -> listOf("testing")
SimplexTLD.web -> emptyList()
}
return (subDomain.reversed() + domain + tld).joinToString(".")
}
}
@Serializable
enum class SimplexTLD {
@@ -4897,6 +4929,14 @@ enum class SimplexNameType {
@SerialName("contact") contact
}
// peer's signed name claim; UI only checks presence
@Serializable
data class NameClaimProof(
val presHeader: String,
val signature: String,
val linkOwnerId: String? = null
)
@Serializable
enum class FormatColor(val color: String) {
red("red"),
@@ -122,6 +122,7 @@ class AppPreferences {
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, true)
val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true)
val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, false)
// TODO remove
@@ -397,6 +398,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES = "PrivacyVerifySimplexNames"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "PrivacyLinkPreviewsShowAlert"
private const val SHARED_PREFS_PRIVACY_SANITIZE_LINKS = "PrivacySanitizeLinks"
private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove
@@ -1555,6 +1557,27 @@ object ChatController {
generalGetString(MR.strings.link_requires_newer_app_version_please_upgrade)
)
}
r is API.Error && r.err is ChatError.ChatErrorChat
&& r.err.errorType is ChatErrorType.SimplexName -> {
if (r.err.errorType.simplexNameError is SimplexNameError.NoValidLink) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cannot_reconnect_via_simplex_name),
generalGetString(MR.strings.simplex_name_unprepared_desc)
)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.simplex_name_not_found),
generalGetString(MR.strings.simplex_name_not_found_desc)
)
}
}
r is API.Error && r.err is ChatError.ChatErrorAgent
&& r.err.agentError is AgentErrorType.NO_NAME_SERVERS -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.simplex_name_resolution_unavailable),
generalGetString(MR.strings.simplex_name_resolver_unavailable_desc)
)
}
r is API.Error && r.err is ChatError.ChatErrorAgent
&& r.err.agentError is AgentErrorType.SMP
&& r.err.agentError.smpErr is SMPErrorType.AUTH -> {
@@ -1592,6 +1615,12 @@ object ChatController {
generalGetString(MR.strings.invalid_connection_link)
e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UnsupportedConnReq ->
generalGetString(MR.strings.unsupported_connection_link)
e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.SimplexName ->
if (e.errorType.simplexNameError is SimplexNameError.NoValidLink)
generalGetString(MR.strings.cannot_reconnect_via_simplex_name)
else generalGetString(MR.strings.simplex_name_not_found)
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.NO_NAME_SERVERS ->
generalGetString(MR.strings.simplex_name_resolution_unavailable)
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH ->
generalGetString(MR.strings.connection_error_auth)
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.BLOCKED ->
@@ -1762,6 +1791,31 @@ object ChatController {
}
}
// name is the encoded SimplexName (e.g. "@alice.simplex"); null clears it. Throws on rejection.
suspend fun apiSetUserName(rh: Long?, name: String?): User {
val userId = currentUserId("apiSetUserName")
val r = sendCmd(rh, CC.ApiSetUserName(userId, name))
return when {
r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh)
r is API.Result && r.res is CR.UserProfileNoChange -> r.res.user.updateRemoteHostId(rh)
else -> throw Exception("failed to set SimpleX name: ${r.responseType} ${r.details}")
}
}
suspend fun apiVerifyContactName(rh: Long?, contactId: Long): Pair<Contact, String?>? {
val r = sendCmd(rh, CC.ApiVerifyContactName(contactId))
if (r is API.Result && r.res is CR.ContactNameVerified) return r.res.contact to r.res.verificationFailure
Log.e(TAG, "apiVerifyContactName bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiVerifyPublicGroupName(rh: Long?, groupId: Long): Pair<GroupInfo, String?>? {
val r = sendCmd(rh, CC.ApiVerifyPublicGroupName(groupId))
if (r is API.Result && r.res is CR.GroupNameVerified) return r.res.groupInfo to r.res.verificationFailure
Log.e(TAG, "apiVerifyPublicGroupName bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? {
val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs))
if (r is API.Result && r.res is CR.ContactPrefsUpdated) return r.res.toContact
@@ -2289,7 +2343,7 @@ object ChatController {
return when {
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
r is API.Error -> {
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}")
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), r.err.string)
null
}
else -> {
@@ -2303,6 +2357,21 @@ object ChatController {
}
}
suspend fun apiSetPublicGroupAccess(rh: Long?, groupId: Long, access: PublicGroupAccess): GroupInfo? {
val r = sendCmd(rh, CC.ApiSetPublicGroupAccess(groupId, access))
return when {
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
r is API.Error -> {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), r.err.string)
null
}
else -> {
Log.e(TAG, "apiSetPublicGroupAccess bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? {
val r = sendCmdWithRetry(rh, CC.APICreateGroupLink(groupId, memberRole))
if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.groupLink
@@ -3705,6 +3774,7 @@ sealed class CC {
class ApiLeaveGroup(val groupId: Long): CC()
class ApiListMembers(val groupId: Long): CC()
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
class ApiSetPublicGroupAccess(val groupId: Long, val access: PublicGroupAccess): CC()
class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC()
class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC()
class APIDeleteGroupLink(val groupId: Long): CC()
@@ -3775,6 +3845,9 @@ sealed class CC {
class ApiShowMyAddress(val userId: Long): CC()
class ApiAddMyAddressShortLink(val userId: Long): CC()
class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC()
class ApiSetUserName(val userId: Long, val name: String?): CC()
class ApiVerifyContactName(val contactId: Long): CC()
class ApiVerifyPublicGroupName(val groupId: Long): CC()
class ApiSetAddressSettings(val userId: Long, val addressSettings: AddressSettings): CC()
class ApiGetCallInvitations: CC()
class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC()
@@ -3983,6 +4056,10 @@ sealed class CC {
is ApiShowMyAddress -> "/_show_address $userId"
is ApiAddMyAddressShortLink -> "/_short_link_address $userId"
is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}"
is ApiSetUserName -> "/_set_name $userId" + (if (name != null) " $name" else "")
is ApiSetPublicGroupAccess -> "/_public group access #$groupId ${json.encodeToString(access)}"
is ApiVerifyContactName -> "/_verify name @$contactId"
is ApiVerifyPublicGroupName -> "/_verify name #$groupId"
is ApiSetAddressSettings -> "/_address_settings $userId ${json.encodeToString(addressSettings)}"
is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
@@ -4164,6 +4241,10 @@ sealed class CC {
is ApiShowMyAddress -> "apiShowMyAddress"
is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink"
is ApiSetProfileAddress -> "apiSetProfileAddress"
is ApiSetUserName -> "apiSetUserName"
is ApiSetPublicGroupAccess -> "apiSetPublicGroupAccess"
is ApiVerifyContactName -> "apiVerifyContactName"
is ApiVerifyPublicGroupName -> "apiVerifyPublicGroupName"
is ApiSetAddressSettings -> "apiSetAddressSettings"
is ApiAcceptContact -> "apiAcceptContact"
is ApiRejectContact -> "apiRejectContact"
@@ -4443,8 +4524,8 @@ data class ServerOperator(
serverDomains = listOf("simplex.im"),
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false),
enabled = true,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
)
}
@@ -4504,7 +4585,8 @@ data class ServerOperator(
@Serializable
data class ServerRoles(
val storage: Boolean,
val proxy: Boolean
val proxy: Boolean,
val names: Boolean
)
@Serializable
@@ -4526,8 +4608,8 @@ data class UserOperatorServers(
serverDomains = emptyList(),
conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
)
companion object {
@@ -4613,6 +4695,7 @@ sealed class UserServersError {
@Serializable
sealed class UserServersWarning {
@Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning()
@Serializable @SerialName("noNamesServers") data class NoNamesServers(val user: UserRef? = null): UserServersWarning()
val globalWarning: String?
get() = when (this) {
@@ -4622,6 +4705,12 @@ sealed class UserServersWarning {
String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text
} else text
}
is NoNamesServers -> {
val text = generalGetString(MR.strings.no_names_servers_enabled)
if (user != null) {
String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text
} else text
}
}
}
@@ -6462,6 +6551,8 @@ sealed class CR {
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR()
@Serializable @SerialName("contactNameVerified") class ContactNameVerified(val user: UserRef, val contact: Contact, val verificationFailure: String? = null): CR()
@Serializable @SerialName("groupNameVerified") class GroupNameVerified(val user: UserRef, val groupInfo: GroupInfo, val verificationFailure: String? = null): CR()
@Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>, val relaysChanged: Boolean): CR()
@Serializable @SerialName("groupRelayUpdated") class GroupRelayUpdated(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val groupRelay: GroupRelay): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR()
@@ -6653,6 +6744,8 @@ sealed class CR {
is JoinedGroupMember -> "joinedGroupMember"
is ConnectedToGroupMember -> "connectedToGroupMember"
is GroupUpdated -> "groupUpdated"
is ContactNameVerified -> "contactNameVerified"
is GroupNameVerified -> "groupNameVerified"
is GroupLinkDataUpdated -> "groupLinkDataUpdated"
is GroupRelayUpdated -> "groupRelayUpdated"
is GroupLinkCreated -> "groupLinkCreated"
@@ -6837,6 +6930,8 @@ sealed class CR {
is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact")
is GroupUpdated -> withUser(user, json.encodeToString(toGroup))
is ContactNameVerified -> withUser(user, "contact: ${json.encodeToString(contact)}\nverificationFailure: $verificationFailure")
is GroupNameVerified -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nverificationFailure: $verificationFailure")
is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged")
is GroupRelayUpdated -> withUser(user, "groupInfo: $groupInfo\nmember: $member\ngroupRelay: $groupRelay")
is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink")
@@ -6960,6 +7055,12 @@ sealed class OwnerVerification {
@Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification()
}
@Serializable
sealed class SimplexNameError {
@Serializable @SerialName("noValidLink") object NoValidLink : SimplexNameError()
@Serializable @SerialName("unknownName") object UnknownName : SimplexNameError()
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -6978,7 +7079,7 @@ sealed class InvitationLinkPlan {
@Serializable
sealed class ContactAddressPlan {
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan()
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedName: SimplexNameInfo? = null): ContactAddressPlan()
@Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
@@ -6988,7 +7089,7 @@ sealed class ContactAddressPlan {
@Serializable
sealed class GroupLinkPlan {
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan()
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedName: SimplexNameInfo? = null): GroupLinkPlan()
@Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
@@ -7297,6 +7398,7 @@ sealed class ChatErrorType {
is ChatStoreChanged -> "chatStoreChanged"
is ConnectionPlanChatError -> "connectionPlan"
is InvalidConnReq -> "invalidConnReq"
is SimplexName -> "simplexName"
is UnsupportedConnReq -> "unsupportedConnReq"
is InvalidChatMessage -> "invalidChatMessage"
is ConnReqMessageProhibited -> "connReqMessageProhibited"
@@ -7379,6 +7481,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType()
@Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType()
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
@Serializable @SerialName("simplexName") class SimplexName(val simplexName: SimplexNameInfo, val simplexNameError: SimplexNameError): ChatErrorType()
@Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType()
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
@Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType()
@@ -7647,6 +7750,7 @@ sealed class AgentErrorType {
is INTERNAL -> "INTERNAL $internalErr"
is CRITICAL -> "CRITICAL $offerRestart $criticalErr"
is INACTIVE -> "INACTIVE"
is NO_NAME_SERVERS -> "NO_NAME_SERVERS"
}
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType, val errContext: String): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType, val errContext: String): AgentErrorType()
@@ -7661,6 +7765,19 @@ sealed class AgentErrorType {
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
@Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType()
@Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType()
@Serializable @SerialName("NO_NAME_SERVERS") object NO_NAME_SERVERS: AgentErrorType()
}
@Serializable
sealed class NameErrorType {
val string: String get() = when (this) {
is NO_RESOLVER -> "NO_RESOLVER"
is NOT_FOUND -> "NOT_FOUND"
is RESOLVER -> "RESOLVER $resolverErr"
}
@Serializable @SerialName("NO_RESOLVER") object NO_RESOLVER: NameErrorType()
@Serializable @SerialName("NOT_FOUND") object NOT_FOUND: NameErrorType()
@Serializable @SerialName("RESOLVER") class RESOLVER(val resolverErr: String): NameErrorType()
}
@Serializable
@@ -7730,6 +7847,7 @@ sealed class SMPErrorType {
is LARGE_MSG -> "LARGE_MSG"
is EXPIRED -> "EXPIRED"
is INTERNAL -> "INTERNAL"
is NAME -> "NAME ${nameErr.string}"
}
@Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
@Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
@@ -7744,6 +7862,7 @@ sealed class SMPErrorType {
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
@Serializable @SerialName("EXPIRED") class EXPIRED: SMPErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
@Serializable @SerialName("NAME") class NAME(val nameErr: NameErrorType): SMPErrorType()
}
@Serializable
@@ -757,6 +757,21 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
)
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
val contactDomain = contact.profile.simplexName?.shortName
if (contactDomain != null && contact.profile.simplexName?.proof != null) {
SimplexNameView(
name = contactDomain,
verification = contact.profile.contactDomainVerification,
autoVerify = chatModel.controller.appPrefs.privacyVerifySimplexNames.get(),
verify = {
val rhId = chatModel.remoteHostId()
chatModel.controller.apiVerifyContactName(rhId, contact.contactId)?.let { (ct, reason) ->
chatModel.chatsContext.updateContact(rhId, ct)
ct.profile.contactDomainVerification to reason
}
}
)
}
}
}
@@ -0,0 +1,95 @@
package chat.simplex.common.views.chat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.SimplexNameInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
// Renders a contact's / channel's SimpleX name with its 3-state verification indicator.
// `verification`: null = not attempted, false = failed, true = verified.
// `verify` runs the verify API, updates the model and returns (newVerification, failureReason);
// null on network error. With `autoVerify`, it runs once on open when state is null.
@Composable
fun SimplexNameView(
name: String,
verification: Boolean?,
autoVerify: Boolean,
verify: suspend () -> Pair<Boolean?, String?>?
) {
val scope = rememberCoroutineScope()
val inFlight = remember { mutableStateOf(false) }
val showSpinner = remember { mutableStateOf(false) }
fun runVerify(manual: Boolean) {
if (inFlight.value) return
inFlight.value = true
scope.launch {
// delay the spinner so a fast result on open doesn't flash it
val spinner = launch { delay(300); if (inFlight.value) showSpinner.value = true }
val res = try {
verify()
} catch (e: Exception) {
Log.e(TAG, "verify SimplexName: ${e.stackTraceToString()}")
null
}
spinner.cancel()
inFlight.value = false
showSpinner.value = false
if (res != null) {
val (newV, reason) = res
// show the reason on a manual run, or on an inconclusive auto run (state stayed null)
if (reason != null && (manual || newV == null)) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.simplex_name_not_verified), reason)
}
}
}
}
LaunchedEffect(Unit) {
if (autoVerify && verification == null) runVerify(manual = false)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF)
) {
Text(
name,
style = MaterialTheme.typography.body2.copy(
color = if (verification == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
fontFamily = if (verification == true) FontFamily.Default else FontFamily.Monospace
)
)
when {
showSpinner.value ->
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary)
verification == true ->
Icon(painterResource(MR.images.ic_check_filled), null, Modifier.size(18.dp), tint = MaterialTheme.colors.onBackground)
verification == false ->
Icon(
painterResource(MR.images.ic_close), null, tint = Color.Red,
modifier = Modifier.size(18.dp).clickable { runVerify(manual = true) }
)
else ->
Text(
stringResource(MR.strings.verify_simplex_name_action),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable { runVerify(manual = true) }
)
}
}
}
@@ -49,7 +49,7 @@ fun ChannelWebPageView(
val trimmedPage = webPage.value.trim()
val newAccess = PublicGroupAccess(
groupWebPage = trimmedPage.ifEmpty { null },
groupDomain = access?.groupDomain,
simplexName = access?.simplexName,
domainWebPage = access?.domainWebPage ?: false,
allowEmbedding = allowEmbedding.value
)
@@ -178,6 +178,26 @@ fun ModalData.GroupChatInfoView(
manageWebPage = {
ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) }
},
setSimplexName = {
ModalManager.end.showCustomModal { close ->
SetSimplexNameView(
title = generalGetString(MR.strings.set_simplex_name),
footer = generalGetString(MR.strings.set_channel_simplex_name_footer),
prefix = "#",
initial = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.simplexName?.shortName ?: "",
save = { name ->
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?: PublicGroupAccess()
val newAccess = access.copy(simplexName = name?.let { SimplexNameClaim(it) })
val gInfo = chatModel.controller.apiSetPublicGroupAccess(rhId, groupInfo.groupId, newAccess)
if (gInfo != null) {
withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, gInfo) }
true
} else false
},
close = close
)
}
},
onSearchClicked = onSearchClicked,
deletingItems = deletingItems
)
@@ -510,6 +530,7 @@ fun ModalData.GroupChatInfoLayout(
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
manageWebPage: () -> Unit,
setSimplexName: () -> Unit,
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
onSearchClicked: () -> Unit,
deletingItems: State<Boolean>
@@ -804,6 +825,14 @@ fun ModalData.GroupChatInfoLayout(
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.advanced_options)) {
ChannelWebPageButton(groupInfo, manageWebPage)
if (groupInfo.groupProfile.publicGroup?.publicGroupAccess != null) {
SettingsActionItem(
painterResource(MR.images.ic_verified_user),
stringResource(MR.strings.set_simplex_name),
setSimplexName,
iconColor = MaterialTheme.colors.secondary
)
}
}
}
@@ -945,6 +974,22 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) {
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
)
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess
val groupName = access?.simplexName?.shortName
if (groupName != null && access.simplexName?.proof != null) {
SimplexNameView(
name = groupName,
verification = groupInfo.groupDomainVerification,
autoVerify = chatModel.controller.appPrefs.privacyVerifySimplexNames.get(),
verify = {
val rhId = chatModel.remoteHostId()
chatModel.controller.apiVerifyPublicGroupName(rhId, groupInfo.groupId)?.let { (gInfo, reason) ->
chatModel.chatsContext.updateGroup(rhId, gInfo)
gInfo.groupDomainVerification to reason
}
}
)
}
val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage
if (webPage != null) {
val uriHandler = LocalUriHandler.current
@@ -1436,6 +1481,7 @@ fun PreviewGroupChatInfoLayout() {
manageGroupLink = {},
manageWebPage = {},
onSearchClicked = {},
setSimplexName = {},
deletingItems = remember { mutableStateOf(true) }
)
}
@@ -338,13 +338,10 @@ fun MarkdownText (
withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) }
withAnnotation("SIMPLEX_NAME") { a ->
val idx = a.item.toIntOrNull()
val nameInfo = (idx?.let { formattedText.getOrNull(it) }?.format as? Format.SimplexName)?.nameInfo
val (title, msg) = if (nameInfo?.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
val nameText = idx?.let { formattedText.getOrNull(it) }?.text
// The name string is routed through the same connect path as a
// link; planAndConnect resolves it on the core (name target).
if (nameText != null) uriHandler.openVerifiedSimplexUri(nameText)
}
}
if (hasSecrets) {
@@ -800,7 +800,20 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
searchChatFilteredBySimplexLink.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = null,
cleanup = { searchText.value = TextFieldValue() },
)
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
@@ -31,11 +31,6 @@ suspend fun planAndConnect(
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
): CompletableDeferred<Boolean> {
when (val target = strConnectTarget(shortOrFullLink.trim())) {
is ConnectTarget.Name -> {
showUnsupportedNameAlert(target.nameInfo)
cleanup?.invoke()
return CompletableDeferred(false)
}
is ConnectTarget.Link -> {
if (target.linkType == SimplexLinkType.relay) {
AlertManager.privacySensitive.showAlertMsg(
@@ -46,7 +41,9 @@ suspend fun planAndConnect(
return CompletableDeferred(false)
}
}
null -> {}
// A SimplexName falls through to apiConnectPlan, which resolves it on the
// core (the /_connect plan command accepts a name target, not only a link).
is ConnectTarget.Name, null -> {}
}
connectProgressManager.cancelConnectProgress()
val inProgress = mutableStateOf(true)
@@ -204,6 +201,12 @@ private suspend fun planAndConnectTask(
is ContactAddressPlan.Known -> {
Log.d(TAG, "planAndConnect, .ContactAddress, .Known")
val contact = connectionPlan.contactAddressPlan.contact
// A name-resolved contact is prepared in the store but not yet in the
// chat list (link-prepared chats arrive via NewPreparedChat). Surface it
// so it's visible and openable; no-op if already present.
if (chatModel.getContactChat(contact.contactId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
}
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
@@ -288,6 +291,11 @@ private suspend fun planAndConnectTask(
is GroupLinkPlan.Known -> {
Log.d(TAG, "planAndConnect, .GroupLink, .Known")
val groupInfo = connectionPlan.groupLinkPlan.groupInfo
// Same as ContactAddress.Known: surface a name-resolved (prepared)
// group in the chat list so it's visible and openable.
if (chatModel.getGroupChat(groupInfo.groupId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(groupInfo, groupChatScope = null), chatItems = emptyList()))
}
if (filterKnownGroup != null) {
filterKnownGroup(groupInfo)
} else {
@@ -536,7 +536,20 @@ private fun ContactsSearchBar(
cleanup = { searchText.value = TextFieldValue() }
)
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = close,
cleanup = { searchText.value = TextFieldValue() },
)
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
@@ -679,7 +679,11 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, showQRC
showQRCodeScanner.value = false
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
is ConnectTarget.Name -> {
pastedLink.value = target.text
showQRCodeScanner.value = false
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
}
null -> AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_contact_link),
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
@@ -824,7 +828,7 @@ fun strIsSimplexLink(str: String): Boolean {
sealed class ConnectTarget {
class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget()
class Name(val nameInfo: SimplexNameInfo) : ConnectTarget()
class Name(val text: String, val nameInfo: SimplexNameInfo) : ConnectTarget()
}
fun strConnectTarget(str: String): ConnectTarget? {
@@ -835,21 +839,13 @@ fun strConnectTarget(str: String): ConnectTarget? {
return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText)
}
if (links.isEmpty()) {
val nameInfo = parsedMd.firstNotNullOfOrNull { (it.format as? Format.SimplexName)?.nameInfo }
if (nameInfo != null) return ConnectTarget.Name(nameInfo)
val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName }
val nameInfo = (nameFt?.format as? Format.SimplexName)?.nameInfo
if (nameFt != null && nameInfo != null) return ConnectTarget.Name(nameFt.text, nameInfo)
}
return null
}
fun showUnsupportedNameAlert(nameInfo: SimplexNameInfo) {
val (title, msg) = if (nameInfo.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
}
@Composable
fun IncognitoToggle(
incognitoPref: SharedPreference<Boolean>,
@@ -134,6 +134,11 @@ fun MorePrivacyView(chatModel: ChatModel) {
chatModel.draftChatId.value = null
}
})
SettingsPreferenceItem(
painterResource(MR.images.ic_verified_user),
stringResource(MR.strings.verify_simplex_names),
chatModel.controller.appPrefs.privacyVerifySimplexNames
)
}
SectionDividerSpaced()
@@ -0,0 +1,75 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import chat.simplex.common.platform.*
import chat.simplex.common.views.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
// Set the user's own (prefix "@") or a channel's (prefix "#") SimpleX name.
// The field is prefilled with the full prefixed name; `save` receives the encoded name (or null to
// clear) and returns true on success (it shows its own error alert otherwise).
@Composable
fun SetSimplexNameView(
title: String,
footer: String,
prefix: String,
initial: String,
save: suspend (String?) -> Boolean,
close: () -> Unit
) {
val name = rememberSaveable { mutableStateOf(initial) }
val saving = remember { mutableStateOf(false) }
val unchanged = name.value.trim() == initial.trim()
fun normalized(): String? {
val s = name.value.trim()
return when {
s.isEmpty() -> null
s.startsWith("@") || s.startsWith("#") -> prefix + s.substring(1)
else -> prefix + s
}
}
val doSave: () -> Unit = {
withBGApi {
saving.value = true
val ok = try { save(normalized()) } catch (e: Exception) {
Log.e(TAG, "SetSimplexNameView save: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), e.message ?: "")
false
}
saving.value = false
if (ok) withContext(Dispatchers.Main) { close() }
}
}
ModalView(close = close) {
ColumnWithScrollBar {
AppBarTitle(title)
SectionView {
PlainTextEditor(name, placeholder = prefix + stringResource(MR.strings.simplex_name_placeholder))
}
SectionTextFooter(footer)
SectionDividerSpaced()
SectionView {
SectionItemView(doSave, disabled = unchanged || saving.value) {
Text(
stringResource(MR.strings.save_verb),
color = if (unchanged || saving.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
SectionBottomSpacer()
}
}
}
@@ -34,6 +34,8 @@ import chat.simplex.common.views.chat.*
import chat.simplex.common.views.newchat.*
import chat.simplex.common.BuildConfigCommon
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun UserAddressView(
@@ -355,6 +357,32 @@ private fun UserAddressLayout(
// ShareViaEmailButton { sendEmail(userAddress) }
BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) }
AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings)
SettingsActionItem(
painterResource(MR.images.ic_verified_user),
stringResource(MR.strings.set_simplex_name),
click = {
ModalManager.start.showCustomModal { close ->
SetSimplexNameView(
title = generalGetString(MR.strings.set_simplex_name),
footer = generalGetString(MR.strings.set_user_simplex_name_footer),
prefix = "@",
initial = user?.profile?.simplexName?.shortName ?: "",
save = { name ->
try {
val u = chatModel.controller.apiSetUserName(user?.remoteHostId, name)
withContext(Dispatchers.Main) { chatModel.updateUser(u) }
true
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), e.message ?: "")
false
}
},
close = close
)
}
},
iconColor = MaterialTheme.colors.secondary
)
}
if (addressSettingsState.value.businessAddress) {
SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations))
@@ -276,20 +276,21 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) {
) {
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
val serversErrs = globalServersErrors(serverErrors.value)
if (serversErrs.isNotEmpty()) {
serversErrs.forEach { err ->
SectionCustomFooter {
ServersErrorFooter(err)
}
}
} else if (serverErrors.value.isNotEmpty()) {
SectionCustomFooter {
ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration))
}
}
val serversWarn = globalServersWarning(serverWarnings.value)
if (serversWarn != null) {
globalServersWarnings(serverWarnings.value).forEach { warn ->
SectionCustomFooter {
ServersWarningFooter(serversWarn)
ServersWarningFooter(warn)
}
}
@@ -951,23 +952,11 @@ fun serversCanBeSaved(
return userServers != currUserServers && serverErrors.isEmpty()
}
fun globalServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalError != null) {
return err.globalError
}
}
return null
}
fun globalServersErrors(serverErrors: List<UserServersError>): List<String> =
serverErrors.mapNotNull { it.globalError }
fun globalServersWarning(serverWarnings: List<UserServersWarning>): String? {
for (warn in serverWarnings) {
if (warn.globalWarning != null) {
return warn.globalWarning
}
}
return null
}
fun globalServersWarnings(serverWarnings: List<UserServersWarning>): List<String> =
serverWarnings.mapNotNull { it.globalWarning }
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
@@ -211,15 +211,19 @@ fun OperatorViewLayout(
rhId = rhId
)
}
val serversErr = globalServersError(serverErrors.value)
val serversWarn = globalServersWarning(serverWarnings.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
val serversErrs = globalServersErrors(serverErrors.value)
val serversWarns = globalServersWarnings(serverWarnings.value)
if (serversErrs.isNotEmpty()) {
serversErrs.forEach { err ->
SectionCustomFooter {
ServersErrorFooter(err)
}
}
} else if (serversWarn != null) {
SectionCustomFooter {
ServersWarningFooter(serversWarn)
} else if (serversWarns.isNotEmpty()) {
serversWarns.forEach { warn ->
SectionCustomFooter {
ServersWarningFooter(warn)
}
}
} else {
val footerText = when (val c = operator.conditionsAcceptance) {
@@ -267,7 +271,7 @@ fun OperatorViewLayout(
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false)
)
)
}
@@ -287,7 +291,27 @@ fun OperatorViewLayout(
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled)
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled, names = false)
)
)
}
}
)
}
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_names),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.smpRoles.names,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(names = enabled) ?: ServerRoles(storage = false, proxy = false, names = enabled)
)
)
}
@@ -371,7 +395,7 @@ fun OperatorViewLayout(
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false)
)
)
}
@@ -185,16 +185,14 @@ fun YourServersViewLayout(
iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
globalServersErrors(serverErrors.value).forEach { err ->
SectionCustomFooter {
ServersErrorFooter(serversErr)
ServersErrorFooter(err)
}
}
val serversWarn = globalServersWarning(serverWarnings.value)
if (serversWarn != null) {
globalServersWarnings(serverWarnings.value).forEach { warn ->
SectionCustomFooter {
ServersWarningFooter(serversWarn)
ServersWarningFooter(warn)
}
}
SectionDividerSpaced()
@@ -146,6 +146,7 @@
<string name="for_chat_profile">For chat profile %s:</string>
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
<string name="no_chat_relays_enabled">No chat relays enabled.</string>
<string name="no_names_servers_enabled">No servers to resolve names.</string>
<string name="server_warning">Server warning</string>
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
<string name="blocking_reason_spam">Spam</string>
@@ -199,6 +200,12 @@
<string name="channel_name_requires_newer_app_version">Connecting via channel name requires a newer app version.</string>
<string name="contact_name_requires_newer_app_version">Connecting via contact name requires a newer app version.</string>
<string name="please_upgrade_the_app">Please upgrade the app.</string>
<string name="simplex_name_not_found">SimpleX name not found</string>
<string name="simplex_name_not_found_desc">There is no contact or group registered with this SimpleX name.</string>
<string name="cannot_reconnect_via_simplex_name">Cannot reconnect via name</string>
<string name="simplex_name_unprepared_desc">This SimpleX name is known but has no saved link to reconnect via.</string>
<string name="simplex_name_resolution_unavailable">Name resolution unavailable</string>
<string name="simplex_name_resolver_unavailable_desc">None of your SMP servers support resolving SimpleX names. Add a server that does, or use a connection link.</string>
<string name="channel_temporarily_unavailable">Channel temporarily unavailable</string>
<string name="channel_no_active_relays_try_later">Channel has no active relays. Please try to join later.</string>
<string name="app_update_required">App update required</string>
@@ -929,7 +936,15 @@
<string name="paste_link">Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
<string name="simplex_address">SimpleX address and name</string>
<string name="verify_simplex_name_action">Verify name</string>
<string name="verify_simplex_names">Verify SimpleX names</string>
<string name="simplex_name_not_verified">SimpleX name not verified</string>
<string name="set_simplex_name">Set SimpleX name</string>
<string name="simplex_name_placeholder">name.simplex</string>
<string name="error_saving_simplex_name">Error saving SimpleX name</string>
<string name="set_user_simplex_name_footer">Set a SimpleX name so people can connect to you using @yourname instead of a link. The name must already be registered to your address.</string>
<string name="set_channel_simplex_name_footer">Set a SimpleX name so people can find this channel as #name. The name must be registered to this channel\'s address.</string>
<string name="or_show_this_qr_code">Or show this code</string>
<string name="full_link_button_text">Full link</string>
<string name="short_link_button_text">Short link</string>
@@ -2153,6 +2168,7 @@
<string name="operator_use_for_messages">Use for messages</string>
<string name="operator_use_for_messages_receiving">To receive</string>
<string name="operator_use_for_messages_private_routing">For private routing</string>
<string name="operator_use_for_names">To resolve names</string>
<string name="operator_added_message_servers">Added message servers</string>
<string name="operator_use_for_files">Use for files</string>
<string name="operator_use_for_sending">To send</string>