From 15a2d77a0fe54371b372df95e29c42854850ee55 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 25 Mar 2026 11:31:41 +1100 Subject: [PATCH 01/17] fix: pin ElectrumX server certs instead of trust-all TrustManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Samsung One UI 7 (Android 16) silently rejects TLS connections that use a no-op X509TrustManager which accepts all certificates. This breaks Namecoin resolution on all Samsung devices running One UI 7, including Galaxy A15 (SM-A156E) and Galaxy S24 Ultra (SM-S938B). Replace trustAllSslFactory() with pinnedSslFactory() that: - Pins the actual self-signed certificates of known ElectrumX servers - Also includes system CA certificates for servers with real certs - Uses a proper TrustManagerFactory chain that Samsung Knox accepts Additional OEM compatibility hardening: - Cache the SSLSocketFactory (avoid expensive rebuild per connection) - Request TLSv1.2 explicitly (Xiaomi MIUI/HyperOS and OnePlus ColorOS Conscrypt forks may default to TLS 1.0 for raw socket upgrades) - Enforce TLSv1.2+ enabled protocols on the SSLSocket - KeyStore fallback to PKCS12 if default type fails (Xiaomi) - Defensive try/catch on system CA cert re-insertion (some OEMs return certs that cannot be added to a new KeyStore) Tested on Android 16 (API 36) emulator — all ElectrumX servers connect (hostname, IP-address, factory reuse) and .bit resolution works E2E. Pinned certs: - electrumx.testls.space:50002 (expires 2027-05-04) - nmc2.bitcoins.sk:57002 (expires 2030-10-22) --- .../namecoin/ElectrumXClient.kt | 220 ++++++++++++++---- 1 file changed, 178 insertions(+), 42 deletions(-) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index b0ba1d6b7..c90342bd2 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -38,19 +38,21 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import java.io.BufferedReader +import java.io.ByteArrayInputStream import java.io.InputStreamReader import java.io.PrintWriter import java.net.InetSocketAddress import java.net.Socket +import java.security.KeyStore import java.security.MessageDigest import java.security.SecureRandom -import java.security.cert.X509Certificate +import java.security.cert.CertificateFactory import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import javax.net.SocketFactory import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager /** @@ -87,25 +89,6 @@ class ElectrumXClient( private val requestId = AtomicInteger(0) private val serverMutexes = ConcurrentHashMap() - companion object { - private const val PROTOCOL_VERSION = "1.4" - - /** - * Namecoin names expire this many blocks after their last update. - * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 - * (~250 days at ~10 min/block). - */ - const val NAME_EXPIRE_DEPTH = 36_000 - - // Namecoin script opcodes - private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin - private const val OP_2DROP: Byte = 0x6d - private const val OP_DROP: Byte = 0x75 - private const val OP_RETURN: Byte = 0x6a - private const val OP_PUSHDATA1: Byte = 0x4c - private const val OP_PUSHDATA2: Byte = 0x4d - } - /** * Perform a name_show lookup against the given ElectrumX server. * @@ -461,41 +444,194 @@ class ElectrumXClient( if (!server.useSsl) return baseSocket // Upgrade to TLS over the already-connected (possibly proxied) socket. + // When the server uses a self-signed certificate (trustAllCerts flag), + // we use a pinned trust store that contains the known ElectrumX server + // certs. This is required because Samsung One UI 7 (Android 16) silently + // rejects connections that use a no-op "trust-all" X509TrustManager. val sslFactory = if (server.trustAllCerts) { - trustAllSslFactory() + cachedPinnedSslFactory() } else { SSLSocketFactory.getDefault() as SSLSocketFactory } - return sslFactory.createSocket(baseSocket, server.host, server.port, true) + val sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true) + + // Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS) + // may negotiate TLS 1.0/1.1 by default for raw socket upgrades. + if (sslSocket is javax.net.ssl.SSLSocket) { + val supported = sslSocket.supportedProtocols + val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (modern.isNotEmpty()) { + sslSocket.enabledProtocols = modern.toTypedArray() + } + } + + return sslSocket + } + + /** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */ + @Volatile + private var pinnedFactory: SSLSocketFactory? = null + + private fun cachedPinnedSslFactory(): SSLSocketFactory { + pinnedFactory?.let { return it } + synchronized(this) { + pinnedFactory?.let { return it } + return buildPinnedSslFactory().also { pinnedFactory = it } + } } /** - * Create an SSLSocketFactory that accepts any certificate. - * Used for servers with self-signed certificates. + * Build an SSLSocketFactory that trusts the pinned ElectrumX server + * certificates plus the system CA store. + * + * Previous versions used a "trust-all" TrustManager, but Samsung + * devices running One UI 7 (Android 16) silently reject connections + * that use a no-op X509TrustManager. Pinning the known self-signed + * certs avoids this while maintaining security. + * + * Also handles OEM-specific quirks: + * - Xiaomi MIUI/HyperOS: KeyStore.getDefaultType() may return unexpected + * types; we try the default first, then fall back to "PKCS12". + * - OnePlus ColorOS: some versions require explicit TLSv1.2 protocol. + * - All OEMs: SSLContext("TLSv1.2") is preferred over ("TLS") which may + * resolve to TLS 1.0 on older Conscrypt forks. */ - private fun trustAllSslFactory(): SSLSocketFactory { - val trustAllCerts = - arrayOf( - object : X509TrustManager { - override fun checkClientTrusted( - chain: Array, - authType: String, - ) {} + private fun buildPinnedSslFactory(): SSLSocketFactory { + val ks = + try { + KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } + } catch (_: Exception) { + // Fallback for Xiaomi devices where getDefaultType() returns an unsupported type + KeyStore.getInstance("PKCS12").apply { load(null, null) } + } - override fun checkServerTrusted( - chain: Array, - authType: String, - ) {} + val cf = CertificateFactory.getInstance("X.509") - override fun getAcceptedIssuers(): Array = arrayOf() - }, - ) - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, trustAllCerts, SecureRandom()) + // Load each pinned certificate into the keystore + for ((index, pem) in PINNED_ELECTRUMX_CERTS.withIndex()) { + try { + val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII))) + ks.setCertificateEntry("electrumx_$index", cert) + } catch (_: Exception) { + // Skip malformed certs — the remaining ones may still work + } + } + + // Also load system CA certificates so that servers with real certs work too + val systemTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + systemTmf.init(null as KeyStore?) // null = system default + val systemTm = systemTmf.trustManagers.filterIsInstance().firstOrNull() + if (systemTm != null) { + for ((index, issuer) in systemTm.acceptedIssuers.withIndex()) { + try { + ks.setCertificateEntry("system_$index", issuer) + } catch (_: Exception) { + // Some OEMs return certs that can't be re-inserted; skip + } + } + } + + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(ks) + + // Prefer TLSv1.2 explicitly — SSLContext.getInstance("TLS") can resolve + // to TLS 1.0 on some OEM Conscrypt forks (Xiaomi, OnePlus). + val sslContext = + try { + SSLContext.getInstance("TLSv1.2") + } catch (_: Exception) { + SSLContext.getInstance("TLS") + } + sslContext.init(null, tmf.trustManagers, SecureRandom()) return sslContext.socketFactory } + companion object { + private const val PROTOCOL_VERSION = "1.4" + + /** + * Namecoin names expire this many blocks after their last update. + * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 + * (~250 days at ~10 min/block). + */ + const val NAME_EXPIRE_DEPTH = 36_000 + + // Namecoin script opcodes + private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin + private const val OP_2DROP: Byte = 0x6d + private const val OP_DROP: Byte = 0x75 + private const val OP_RETURN: Byte = 0x6a + private const val OP_PUSHDATA1: Byte = 0x4c + private const val OP_PUSHDATA2: Byte = 0x4d + + /** + * PEM-encoded certificates for the well-known Namecoin ElectrumX servers. + * + * These are self-signed certificates that cannot be verified by the + * system CA store. We pin them explicitly so that connections succeed + * on devices with strict TLS enforcement (e.g. Samsung One UI 7). + * + * To update: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM` + */ + private val PINNED_ELECTRUMX_CERTS = + listOf( + // electrumx.testls.space:50002 — expires 2027-05-04 + """ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTAeFw0yMjA1MDUwNjIzNDFaFw0yNzA1MDQwNjIzNDFaMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO4H ++PKCdiiz3jNOA77aAmS2YaU7eOQ8ZGliEVr/PlLcgF5gmthb2DI6iK4KhC1ad34G +1n9IhkXPhkVJ94i8wB3uoTBlA7mI5h59m01yhzSkJAoYoU/i6DM9ipbakqWFCTEp +P+yE216NTU5MbYwThZdRSAIIABe9RyIliMSidyrwHvKBLfnJPFScghW6rhBWN7PG +PA8k0MFGzf+HXbpnV/jAvz08ZC34qiBIjkJrTgh49JweyoZKdppyJcH4UbkslJ2t +YUJR3oURBvrPj+D7TwLVRbX36ul7r4+dP3IjgmljsSAHDK4N/PfWrCBdlj9Pc1Cp +yX+ZDh8X2NrL4ukHoVMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeVj6VZNmY/Vb +nhzrC7xBSHqVWQ1wkLOClLsdvgKP8cFFJuUoCMQU5bPMi7nWnkfvvsIKH4Eibk5K +fqiA9jVsY0FHvQ8gP3KMk1LVuUf/sTcRe5itp3guBOSk/zXZUD5tUz/oRk3k+rdc +MsInqhomjNy/dqYmD6Wm4DNPjZh6fWy+AVQKVNOI2t4koaVdpoi8Uv8h4gFGPbdI +sVmtoGiIGkKNIWum+6mnF6PfynNrLk+ztH4TrdacVNeoJUPYEAxOuesWXFy3H4r+ +HKBqA4xAzyjgKLPqoWnjSu7gxj1GIjBhnDxkM6wUOnDq8A0EqxR+A17OcXW9sZ2O +2ZIVwmtnyA== +-----END CERTIFICATE----- + """.trimIndent(), + // nmc2.bitcoins.sk:57002 / 46.229.238.187:57002 — expires 2030-10-22 + """ +-----BEGIN CERTIFICATE----- +MIID+TCCAuGgAwIBAgIUdmJGukmfPvqmAYpTfuGcjRoYHJ8wDQYJKoZIhvcNAQEL +BQAwgYsxCzAJBgNVBAYTAlNLMREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwK +QnJhdGlzbGF2YTEUMBIGA1UECgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIu +Yml0Y29pbnMuc2sxIzAhBgkqhkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3Jn +MB4XDTIwMTAyNDE5MjQzOVoXDTMwMTAyMjE5MjQzOVowgYsxCzAJBgNVBAYTAlNL +MREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwKQnJhdGlzbGF2YTEUMBIGA1UE +CgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIuYml0Y29pbnMuc2sxIzAhBgkq +hkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3JnMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAzBUkZNDfaz7kc28l5tDKohJjekWmz1ynzfGx3ZLsqOZE +c+kNfcMaWU+zT/j0mV6pX6KSH7G9pPAku+8PRdKRq+d63wiJDEjGSaFztQWKW6L1 +vTxgCK5gu+Eir3BkTagJObsrLKS+T6qH610/3+btGgoR3lunB5TzCgB/9oQanjDW +zjg2CwmxgR5Iw1Eqfenx7zkSK33FSXSF2SvbUs1Atj2oPU4DLivyrx0RaUmaPemn +cmcpnax+py4pQeB6dJWU1INhzXt3hTJRyoqsSGY3vCECIKIBIkh8GsYjAX4z+Y9y +6pJx0da2b88qPWdsoxaIMvrQiuWknDrSJwAyw2Yd8QIDAQABo1MwUTAdBgNVHQ4E +FgQUT2J83B2/9jxGGdFeWrxMohTzHNwwHwYDVR0jBBgwFoAUT2J83B2/9jxGGdFe +WrxMohTzHNwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAsbxX +wN8tZaXOybImMZCQS7zfxmKl2IAcqu+R01KPfnIfrFqXPsGDDl3rYLkwh1O4/hYQ +NKNW9KTxoJxuBmAkm7EXQQh1XUUzajdEDqDBVRyvR0Z2MdMYnMSAiiMXMl2wUZnc +QXYftBo0HbtfsaJjImQdDjmlmRPSzE/RW6iUe+1cesKBC7e8nVf69Yu/fxO4m083 +VWwAstlWJfk1GyU7jzVc8svealg/oIiDoOMe6CFSLx1BDv2FeHSpRdqd3fn+AC73 +bK2N2smrHUOQnFijuiFw3WOrjERi0eMhjVNfVu9W9ZYa/Wd6SdIzV55LbG+NpmSf +5W7ix41hRvdT6cTAJA== +-----END CERTIFICATE----- + """.trimIndent(), + ) + } + private fun buildRpcRequest( method: String, params: List, From 49698de99cb25cfa1b2de2f593bb12b99d7914a9 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 25 Mar 2026 11:52:05 +1100 Subject: [PATCH 02/17] feat: add Test Connection diagnostics to Namecoin settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-server connection testing with detailed error reporting: - Test Connection button tests each ElectrumX server individually - Shows streaming results: ✅ success with response time, ❌ failure with human-readable error (TLS handshake failed, connection refused, timeout, DNS failed, invalid response, etc.) - Diagnostic card shows: last test timestamp + success count, device info (manufacturer/model/Android/API), TLS version negotiated - ElectrumXClient.testServer() method for single-server diagnostics with TLS version capture from SSL session This gives users (and bug reporters) immediate visibility into why Namecoin resolution may be failing silently on their device. --- .../com/vitorpamplona/amethyst/AppModules.kt | 10 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../settings/NamecoinSettingsScreen.kt | 3 + .../settings/NamecoinSettingsSection.kt | 277 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 11 + .../namecoin/ElectrumXServer.kt | 11 + .../namecoin/ElectrumXClient.kt | 135 +++++++++ 7 files changed, 443 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cf9256dc4..8a7b5f379 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -161,12 +161,14 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) + val electrumXClient = + ElectrumXClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) + val namecoinResolver = NamecoinNameResolver( - electrumxClient = - ElectrumXClient( - socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, - ), + electrumxClient = electrumXClient, serverListProvider = { // User-configured custom servers take priority namecoinPrefs.customServersOrNull diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 057a8c615..e7e42a6b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -200,7 +200,7 @@ fun AppNavigation( composableFromEnd { AccountBackupScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } - composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } + composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, Amethyst.instance.electrumXClient, nav) } composableFromEnd { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 870570194..0f32a6499 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -38,12 +38,14 @@ import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun NamecoinSettingsScreen( namecoinPrefs: NamecoinSharedPreferences, + electrumXClient: ElectrumXClient, nav: INav, ) { val namecoinSettings by namecoinPrefs.settings.collectAsState() @@ -75,6 +77,7 @@ fun NamecoinSettingsScreen( onReset = { scope.launch { namecoinPrefs.reset() } }, + onTestServer = { server -> electrumXClient.testServer(server) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index db747e138..86949a86b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -41,6 +42,10 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,20 +58,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Complete settings section for Namecoin ElectrumX server configuration. @@ -79,6 +93,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S * @param onAddServer Called with `host:port[:tcp]` when user adds a server * @param onRemoveServer Called with the server string to remove * @param onReset Called when user resets to defaults + * @param onTestServer Suspend function to test a single server */ @Composable fun NamecoinSettingsSection( @@ -87,6 +102,7 @@ fun NamecoinSettingsSection( onAddServer: (String) -> Unit, onRemoveServer: (String) -> Unit, onReset: () -> Unit, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, modifier: Modifier = Modifier, ) { Column(modifier = modifier.padding(16.dp)) { @@ -150,12 +166,271 @@ fun NamecoinSettingsSection( } } } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(16.dp)) + + // ── Test Connection ──────────────────────────────── + TestConnectionSection( + settings = settings, + onTestServer = onTestServer, + ) } } } } -// ── Sub-composables ──────────────────────────────────────────────────── +// ── Test Connection ──────────────────────────────────────────────────── + +@Composable +private fun TestConnectionSection( + settings: NamecoinSettings, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, +) { + val scope = rememberCoroutineScope() + var isTesting by remember { mutableStateOf(false) } + var testResults by remember { mutableStateOf>(emptyList()) } + var lastTestTimestamp by remember { mutableStateOf(null) } + + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + + Column { + // ── Test button ──────────────────────────────────────── + Button( + onClick = { + if (!isTesting) { + isTesting = true + testResults = emptyList() + scope.launch { + val results = mutableListOf() + for (server in servers) { + val result = onTestServer(server) + results.add(result) + testResults = results.toList() + } + lastTestTimestamp = System.currentTimeMillis() + isTesting = false + } + } + }, + enabled = !isTesting, + modifier = Modifier.fillMaxWidth(), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.namecoin_testing)) + } else { + Text(stringResource(R.string.namecoin_test_connection)) + } + } + + // ── Per-server results ───────────────────────────────── + if (testResults.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + + Text( + stringResource(R.string.namecoin_test_results), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + + testResults.forEach { result -> + ServerTestResultRow(result) + } + + if (isTesting && testResults.size < servers.size) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Testing next server…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // ── Diagnostic card ──────────────────────────────────── + if (testResults.isNotEmpty() || lastTestTimestamp != null) { + Spacer(Modifier.height(16.dp)) + DiagnosticCard( + testResults = testResults, + lastTestTimestamp = lastTestTimestamp, + ) + } + } +} + +@Composable +private fun ServerTestResultRow(result: ServerTestResult) { + val serverLabel = "${result.server.host}:${result.server.port}" + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = if (result.success) "✅" else "❌", + fontSize = 14.sp, + modifier = Modifier.padding(end = 6.dp, top = 1.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = serverLabel, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.namecoin_response_time, result.responseTimeMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (result.success) { + Text( + text = stringResource(R.string.namecoin_test_success), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF2E8B57), + ) + } else { + val errorText = result.error + if (errorText != null) { + Text( + text = errorText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ── Diagnostic Card ──────────────────────────────────────────────────── + +@Composable +private fun DiagnosticCard( + testResults: List, + lastTestTimestamp: Long?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + stringResource(R.string.namecoin_diagnostics), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + + // Last test timestamp + if (lastTestTimestamp != null) { + val formatted = + remember(lastTestTimestamp) { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + .format(Date(lastTestTimestamp)) + } + val successCount = testResults.count { it.success } + val totalCount = testResults.size + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = "$formatted ($successCount/$totalCount OK)", + ) + } else { + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = stringResource(R.string.namecoin_no_test_yet), + ) + } + + Spacer(Modifier.height(4.dp)) + + // Device info + DiagnosticRow( + label = stringResource(R.string.namecoin_device_info), + value = "${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + ) + + Spacer(Modifier.height(4.dp)) + + // TLS info from test results + val tlsVersions = + testResults + .mapNotNull { it.tlsVersion } + .distinct() + val tlsDisplay = + if (tlsVersions.isNotEmpty()) { + tlsVersions.joinToString(", ") + } else { + "—" + } + DiagnosticRow( + label = stringResource(R.string.namecoin_tls_info), + value = tlsDisplay, + ) + } + } +} + +@Composable +private fun DiagnosticRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.35f), + ) + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(0.65f), + ) + } +} + +// ── Original Sub-composables ─────────────────────────────────────────── @Composable private fun SectionHeader( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 28f63ffea..1f10ced90 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1842,6 +1842,17 @@ Select All %1$d%% uptime Namecoin Settings + Test Connection + Testing servers… + Connected + Failed + Test Results + Diagnostics + Last test + Device Info + TLS Info + No test run yet + %dms Relay Sync Relay Sync Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index f31edfe7d..cc5a172d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -72,6 +72,17 @@ sealed class NamecoinLookupException( ) : NamecoinLookupException("All ElectrumX servers unreachable", lastError) } +/** + * Result of testing connectivity to a single ElectrumX server. + */ +data class ServerTestResult( + val server: ElectrumxServer, + val success: Boolean, + val responseTimeMs: Long, + val error: String? = null, + val tlsVersion: String? = null, +) + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_ELECTRUMX_SERVERS = listOf( diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index c90342bd2..6a4db6688 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -150,6 +150,141 @@ class ElectrumXClient( throw NamecoinLookupException.ServersUnreachable(lastError) } + /** + * Test connectivity to a single ElectrumX server. + * + * Connects, negotiates protocol version, and optionally resolves a test + * name. Returns detailed results including response time, TLS version, + * and human-readable error messages. + * + * @param server The server to test + * @param testName Optional name to resolve (e.g. "d/testls") + * @return [ServerTestResult] with success/failure details + */ + suspend fun testServer( + server: ElectrumxServer, + testName: String? = "d/testls", + ): ServerTestResult = + withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + try { + val socket = createSocket(server) + socket.soTimeout = readTimeoutMs.toInt() + + val tlsVersion = + if (socket is javax.net.ssl.SSLSocket) { + socket.session.protocol + } else { + null + } + + val writer = PrintWriter(socket.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + + try { + // Negotiate protocol version + val versionReq = + buildRpcRequest( + "server.version", + listOf("AmethystNMC/0.1", PROTOCOL_VERSION), + ) + writer.println(versionReq) + val versionResponse = + reader.readLine() + ?: return@withContext ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Server returned empty response", + tlsVersion = tlsVersion, + ) + + // If a test name is provided, try to resolve it + if (testName != null) { + val nameScript = + buildNameIndexScript(testName.toByteArray(Charsets.US_ASCII)) + val scriptHash = electrumScriptHash(nameScript) + val historyReq = + buildRpcRequest( + "blockchain.scripthash.get_history", + listOf(scriptHash), + ) + writer.println(historyReq) + reader.readLine() // consume response + } + + val elapsed = System.currentTimeMillis() - startTime + ServerTestResult( + server = server, + success = true, + responseTimeMs = elapsed, + tlsVersion = tlsVersion, + ) + } finally { + runCatching { writer.close() } + runCatching { reader.close() } + runCatching { socket.close() } + } + } catch (e: java.net.ConnectException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Connection refused", + ) + } catch (e: java.net.SocketTimeoutException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Connection timed out after ${connectTimeoutMs / 1000}s", + ) + } catch (e: java.net.UnknownHostException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Server unreachable (DNS resolution failed)", + ) + } catch (e: javax.net.ssl.SSLHandshakeException) { + val detail = + if (e.message?.contains("self-signed", ignoreCase = true) == true || + e.message?.contains("anchor", ignoreCase = true) == true + ) { + "TLS handshake failed (self-signed certificate rejected)" + } else { + "TLS handshake failed: ${e.message?.take(100) ?: "unknown error"}" + } + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = detail, + ) + } catch (e: javax.net.ssl.SSLException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "TLS error: ${e.message?.take(100) ?: "unknown"}", + ) + } catch (e: java.io.IOException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "I/O error: ${e.message?.take(100) ?: "unknown"}", + ) + } catch (e: Exception) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = e.message?.take(150) ?: "Unknown error", + ) + } + } + // ── internals ────────────────────────────────────────────────────── private fun connectAndQuery( From b8b51db0b5603ee6b1f249eabfcd7fa2aacc0866 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 25 Mar 2026 12:05:05 +1100 Subject: [PATCH 03/17] feat: TOFU cert pinning for custom ElectrumX servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user adds a custom ElectrumX server and runs Test Connection, the server's TLS certificate is automatically captured and pinned (Trust On First Use). This allows custom servers with self-signed certificates to work on Samsung/Xiaomi/OnePlus devices. Changes: - ElectrumXClient: addPinnedCert(), setDynamicCerts() for runtime cert management; testServer() now captures server cert PEM and SHA-256 fingerprint from SSL session - NamecoinSharedPreferences: persist pinned certs to DataStore - AppModules: load pinned certs on startup, sync to ElectrumXClient - NamecoinSettings: custom servers always set trustAllCerts=true (ElectrumX servers almost universally use self-signed certs) - UI: shows cert fingerprint in test results, auto-pins on success - ServerTestResult: new serverCertPem + certFingerprint fields Flow: Add server → Test Connection → cert auto-captured and pinned → future connections trust that cert even on Samsung Knox devices. --- .../com/vitorpamplona/amethyst/AppModules.kt | 12 ++++ .../preferences/NamecoinSharedPreferences.kt | 41 ++++++++++++ .../service/namecoin/NamecoinSettings.kt | 6 +- .../settings/NamecoinSettingsScreen.kt | 6 ++ .../settings/NamecoinSettingsSection.kt | 20 ++++++ .../namecoin/ElectrumXServer.kt | 4 ++ .../namecoin/ElectrumXClient.kt | 64 +++++++++++++++++-- 7 files changed, 145 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8a7b5f379..7bbcb490f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -406,6 +406,18 @@ class AppModules( // Eagerly initialize OtsSharedPreferences off the main thread otsPrefs } + + // Load user-pinned ElectrumX certs from preferences into the client + applicationIOScope.launch { + try { + val pinnedCerts = namecoinPrefs.loadPinnedCerts() + if (pinnedCerts.isNotEmpty()) { + electrumXClient.setDynamicCerts(pinnedCerts) + } + } catch (_: Exception) { + // Non-fatal — defaults will still work + } + } } fun terminate(appContext: Context) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 600cecaa9..7319aa78e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -58,6 +58,7 @@ class NamecoinSharedPreferences( companion object { val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts") } /** @@ -99,8 +100,48 @@ class NamecoinSharedPreferences( suspend fun reset() { persist(NamecoinSettings.DEFAULT) + clearPinnedCerts() } + /** + * Store a PEM-encoded certificate that the user accepted via Test Connection. + * The cert is appended to the existing list and synced to the ElectrumXClient. + */ + suspend fun addPinnedCert(pem: String) { + val existing = loadPinnedCertsFromDisk() + val updated = (existing + pem).distinct() + savePinnedCerts(updated) + } + + /** Load all user-pinned certs from disk (for startup sync). */ + suspend fun loadPinnedCerts(): List = loadPinnedCertsFromDisk() + + private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList()) + + private suspend fun savePinnedCerts(certs: List) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_PINNED_CERTS] = json.encodeToString(certs) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error writing pinned certs: ${e.message}") + } + } + + private suspend fun loadPinnedCertsFromDisk(): List = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val certsJson = prefs[KEY_PINNED_CERTS] + if (certsJson != null) { + json.decodeFromString>(certsJson) + } else { + emptyList() + } + } catch (_: Exception) { + emptyList() + } + // ── Internal ─────────────────────────────────────────────────────── private suspend fun persist(settings: NamecoinSettings) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 4855b493f..1962997dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -77,11 +77,15 @@ data class NamecoinSettings( if (host.isEmpty() || port <= 0 || port > 65535) return null val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" val isOnion = host.endsWith(".onion") + // All custom servers use trustAllCerts — ElectrumX servers + // almost universally use self-signed certificates. The actual + // trust is handled by pinned certs (hardcoded defaults + any + // certs the user has accepted via Test Connection TOFU). return ElectrumxServer( host = host, port = port, useSsl = useSsl, - trustAllCerts = isOnion || !useSsl, + trustAllCerts = true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 0f32a6499..d5ca59881 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -78,6 +78,12 @@ fun NamecoinSettingsScreen( scope.launch { namecoinPrefs.reset() } }, onTestServer = { server -> electrumXClient.testServer(server) }, + onPinCert = { pem -> + scope.launch { + namecoinPrefs.addPinnedCert(pem) + electrumXClient.addPinnedCert(pem) + } + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index 86949a86b..5d0c0af4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -94,6 +94,7 @@ import java.util.Locale * @param onRemoveServer Called with the server string to remove * @param onReset Called when user resets to defaults * @param onTestServer Suspend function to test a single server + * @param onPinCert Called with PEM string to persist a TOFU-pinned cert */ @Composable fun NamecoinSettingsSection( @@ -103,6 +104,7 @@ fun NamecoinSettingsSection( onRemoveServer: (String) -> Unit, onReset: () -> Unit, onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Column(modifier = modifier.padding(16.dp)) { @@ -177,6 +179,7 @@ fun NamecoinSettingsSection( TestConnectionSection( settings = settings, onTestServer = onTestServer, + onPinCert = onPinCert, ) } } @@ -189,6 +192,7 @@ fun NamecoinSettingsSection( private fun TestConnectionSection( settings: NamecoinSettings, onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit, ) { val scope = rememberCoroutineScope() var isTesting by remember { mutableStateOf(false) } @@ -210,6 +214,11 @@ private fun TestConnectionSection( val result = onTestServer(server) results.add(result) testResults = results.toList() + // TOFU: auto-pin cert from successful connections + val pem = result.serverCertPem + if (result.success && pem != null) { + onPinCert(pem) + } } lastTestTimestamp = System.currentTimeMillis() isTesting = false @@ -319,6 +328,17 @@ private fun ServerTestResultRow(result: ServerTestResult) { style = MaterialTheme.typography.labelSmall, color = Color(0xFF2E8B57), ) + val fp = result.certFingerprint + if (fp != null) { + Text( + text = "Cert: ${fp.take(23)}…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } else { val errorText = result.error if (errorText != null) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index cc5a172d4..f683894f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -81,6 +81,10 @@ data class ServerTestResult( val responseTimeMs: Long, val error: String? = null, val tlsVersion: String? = null, + /** PEM-encoded server certificate, captured during test for TOFU pinning. */ + val serverCertPem: String? = null, + /** SHA-256 fingerprint of the server certificate. */ + val certFingerprint: String? = null, ) /** Well-known public Namecoin ElectrumX servers (clearnet). */ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 6a4db6688..65a211ccd 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -171,12 +171,31 @@ class ElectrumXClient( val socket = createSocket(server) socket.soTimeout = readTimeoutMs.toInt() - val tlsVersion = - if (socket is javax.net.ssl.SSLSocket) { - socket.session.protocol - } else { - null + var tlsVersion: String? = null + var serverCertPem: String? = null + var certFingerprint: String? = null + + if (socket is javax.net.ssl.SSLSocket) { + tlsVersion = socket.session.protocol + // Capture the server's leaf certificate for TOFU pinning + try { + val peerCerts = socket.session.peerCertificates + if (peerCerts.isNotEmpty() && peerCerts[0] is java.security.cert.X509Certificate) { + val x509 = peerCerts[0] as java.security.cert.X509Certificate + // PEM encode + val encoded = + java.util.Base64 + .getMimeEncoder(76, "\n".toByteArray()) + .encodeToString(x509.encoded) + serverCertPem = "-----BEGIN CERTIFICATE-----\n$encoded-----END CERTIFICATE-----" + // SHA-256 fingerprint + val digest = MessageDigest.getInstance("SHA-256").digest(x509.encoded) + certFingerprint = digest.joinToString(":") { "%02X".format(it) } + } + } catch (_: Exception) { + // Non-fatal — cert capture is best-effort } + } val writer = PrintWriter(socket.getOutputStream(), true) val reader = BufferedReader(InputStreamReader(socket.getInputStream())) @@ -219,6 +238,8 @@ class ElectrumXClient( success = true, responseTimeMs = elapsed, tlsVersion = tlsVersion, + serverCertPem = serverCertPem, + certFingerprint = certFingerprint, ) } finally { runCatching { writer.close() } @@ -604,6 +625,9 @@ class ElectrumXClient( return sslSocket } + /** User-supplied PEM certificates for custom servers (TOFU-pinned). */ + private val dynamicCerts = mutableListOf() + /** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */ @Volatile private var pinnedFactory: SSLSocketFactory? = null @@ -616,6 +640,31 @@ class ElectrumXClient( } } + /** + * Add a PEM-encoded certificate to the dynamic trust store. + * Typically called after the user confirms a cert fingerprint via + * the "Test Connection" flow in settings. + * + * Invalidates the cached factory so the next connection picks it up. + */ + fun addPinnedCert(pem: String) { + synchronized(this) { + dynamicCerts.add(pem) + pinnedFactory = null // force rebuild + } + } + + /** + * Replace all dynamic certs (e.g. loaded from preferences on startup). + */ + fun setDynamicCerts(pems: List) { + synchronized(this) { + dynamicCerts.clear() + dynamicCerts.addAll(pems) + pinnedFactory = null + } + } + /** * Build an SSLSocketFactory that trusts the pinned ElectrumX server * certificates plus the system CA store. @@ -643,8 +692,9 @@ class ElectrumXClient( val cf = CertificateFactory.getInstance("X.509") - // Load each pinned certificate into the keystore - for ((index, pem) in PINNED_ELECTRUMX_CERTS.withIndex()) { + // Load hardcoded + dynamic pinned certificates into the keystore + val allCerts = PINNED_ELECTRUMX_CERTS + dynamicCerts + for ((index, pem) in allCerts.withIndex()) { try { val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII))) ks.setCertificateEntry("electrumx_$index", cert) From 3afe0c9978b6929b77c6c1b8f1788245340ee722 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 25 Mar 2026 12:10:45 +1100 Subject: [PATCH 04/17] fix: bypass cert pinning for .onion ElectrumX servers Tor hidden services are authenticated by their onion address (the public key hash), making TLS certificate verification redundant. The .onion server in TOR_ELECTRUMX_SERVERS was going through pinnedSslFactory() but its cert isn't in the pinned list (and can't be fetched without Tor). This would cause .onion connections to fail with SSLHandshakeException when Tor mode is active. Fix: .onion addresses now use a dedicated onionSslFactory() with trust-all, which is safe because: 1. Tor provides end-to-end encryption 2. The onion address IS the server identity proof 3. Samsung Knox trust-all rejection doesn't apply to proxied sockets --- .../namecoin/ElectrumXClient.kt | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 65a211ccd..df6495597 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -52,6 +52,7 @@ import java.util.concurrent.atomic.AtomicInteger import javax.net.SocketFactory import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -604,8 +605,14 @@ class ElectrumXClient( // we use a pinned trust store that contains the known ElectrumX server // certs. This is required because Samsung One UI 7 (Android 16) silently // rejects connections that use a no-op "trust-all" X509TrustManager. + // + // Exception: .onion addresses bypass cert pinning entirely — the Tor + // hidden service protocol already provides end-to-end authentication + // via the onion address, making TLS cert verification redundant. val sslFactory = - if (server.trustAllCerts) { + if (server.host.endsWith(".onion")) { + onionSslFactory() + } else if (server.trustAllCerts) { cachedPinnedSslFactory() } else { SSLSocketFactory.getDefault() as SSLSocketFactory @@ -625,6 +632,44 @@ class ElectrumXClient( return sslSocket } + /** + * SSLSocketFactory for .onion addresses. + * + * Tor hidden services are authenticated by their onion address (the + * public key hash), so TLS certificate verification is redundant. + * We use a trust-all factory here — this is safe because: + * 1. The connection is already end-to-end encrypted by Tor. + * 2. The onion address IS the server's identity proof. + * 3. Samsung Knox's trust-all rejection doesn't apply to proxied + * sockets routed through Tor's SOCKS interface. + */ + private fun onionSslFactory(): SSLSocketFactory { + val trustAll = + arrayOf( + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array, + authType: String, + ) {} + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) {} + + override fun getAcceptedIssuers(): Array = arrayOf() + }, + ) + val ctx = + try { + SSLContext.getInstance("TLSv1.2") + } catch (_: Exception) { + SSLContext.getInstance("TLS") + } + ctx.init(null, trustAll, SecureRandom()) + return ctx.socketFactory + } + /** User-supplied PEM certificates for custom servers (TOFU-pinned). */ private val dynamicCerts = mutableListOf() From a4953f628d6346ff4fb42333257f84cf6f1432af Mon Sep 17 00:00:00 2001 From: M Date: Wed, 25 Mar 2026 12:13:49 +1100 Subject: [PATCH 05/17] doc: verify .onion ElectrumX cert via Tor, document pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connected to the .onion hidden service via Tor SOCKS proxy and confirmed it serves the SAME certificate as electrumx.testls.space: SHA-256: 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:... The .onion cert is already covered by the first pinned cert entry. Added comments documenting this relationship and instructions for fetching .onion certs via Tor for future updates. The .onion still uses onionSslFactory() (trust-all) as the primary path — this is correct since Tor provides its own authentication. The pinned cert serves as defense-in-depth. --- .../quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index df6495597..fc831d0e2 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -803,10 +803,14 @@ class ElectrumXClient( * on devices with strict TLS enforcement (e.g. Samsung One UI 7). * * To update: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM` + * For .onion: `python3 -c "import socks,ssl,socket,base64; s=socks.socksocket(); s.set_proxy(socks.SOCKS5,'127.0.0.1',9050); s.connect(('HOST',PORT)); ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE; ss=ctx.wrap_socket(s); print(base64.encodebytes(ss.getpeercert(True)).decode())"` */ private val PINNED_ELECTRUMX_CERTS = listOf( // electrumx.testls.space:50002 — expires 2027-05-04 + // Also covers the .onion hidden service (same operator, same cert): + // i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002 + // SHA-256: 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:A0:EA:7A:99:2D:F7:0F:05:7E:9B:CD:50:36:C7:79:9C """ -----BEGIN CERTIFICATE----- MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd From 7f451ee7c70b5ba15d3a72c03668b6a8ab5a7b86 Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 06:26:18 +1100 Subject: [PATCH 06/17] fix: harden TLS for GrapheneOS and security-conscious Android ROMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address compatibility and security issues identified by reviewing GrapheneOS source (hardened Conscrypt, strict TLS enforcement): 1. .onion: prefer pinned factory, fall back to trust-all GrapheneOS patches Conscrypt with stricter TLS enforcement that may reject no-op X509TrustManagers even for proxied sockets. The .onion server's cert is already in PINNED_ELECTRUMX_CERTS (same operator as electrumx.testls.space), so we now use cachedPinnedSslFactory() as the primary path. onionSslFactory() (trust-all) is kept as a fallback on SSLHandshakeException only — this handles cert rotation or unknown .onion servers gracefully. 2. TOFU: require explicit user confirmation before pinning Previously, Test Connection auto-pinned every cert on success. An attacker performing MITM during first test would get their cert permanently trusted. Now each new cert triggers an AlertDialog showing the full SHA-256 fingerprint. Users must explicitly accept ('Trust') or reject each cert — aligning with GrapheneOS's philosophy of explicit trust decisions. 3. Clarify trustAllCerts semantics The field name is misleading post-refactor: it now means 'use pinned trust store' not 'trust all certificates'. Added TODO to rename to usePinnedTrustStore and updated inline comments to prevent future misinterpretation. Note: hostname verification on raw SSLSocket is a pre-existing gap (not introduced by the Samsung fix PR) — SSLSocket.createSocket() uses the host parameter for SNI only, not hostname verification. A follow-up should add endpointIdentificationAlgorithm='HTTPS' or fingerprint-based verification for pinned certs. --- .../service/namecoin/NamecoinSettings.kt | 10 ++- .../settings/NamecoinSettingsSection.kt | 84 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 4 + .../namecoin/ElectrumXClient.kt | 49 +++++++++-- 4 files changed, 131 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 1962997dd..0f372e577 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -77,10 +77,12 @@ data class NamecoinSettings( if (host.isEmpty() || port <= 0 || port > 65535) return null val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" val isOnion = host.endsWith(".onion") - // All custom servers use trustAllCerts — ElectrumX servers - // almost universally use self-signed certificates. The actual - // trust is handled by pinned certs (hardcoded defaults + any - // certs the user has accepted via Test Connection TOFU). + // All custom servers set trustAllCerts=true, which (despite the + // legacy name) means "use the pinned trust store" rather than + // "trust all certificates". ElectrumX servers almost universally + // use self-signed certs, so we route them through our pinned + // SSLSocketFactory (hardcoded defaults + TOFU-pinned certs). + // TODO: rename trustAllCerts → usePinnedTrustStore for clarity. return ElectrumxServer( host = host, port = port, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index 5d0c0af4b..5db51d692 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -188,6 +189,15 @@ fun NamecoinSettingsSection( // ── Test Connection ──────────────────────────────────────────────────── +/** + * Holds a cert pending user confirmation before pinning (TOFU). + */ +private data class PendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + @Composable private fun TestConnectionSection( settings: NamecoinSettings, @@ -198,9 +208,63 @@ private fun TestConnectionSection( var isTesting by remember { mutableStateOf(false) } var testResults by remember { mutableStateOf>(emptyList()) } var lastTestTimestamp by remember { mutableStateOf(null) } + // Certs discovered during testing that need user confirmation + var pendingCerts by remember { mutableStateOf>(emptyList()) } + // Which cert is currently shown in the confirmation dialog + var confirmingCert by remember { mutableStateOf(null) } val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + // ── Cert confirmation dialog ─────────────────────────────── + confirmingCert?.let { pending -> + AlertDialog( + onDismissRequest = { + // Remove from pending list and move to next (or close) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }, + title = { Text(stringResource(R.string.namecoin_pin_cert_title)) }, + text = { + Column { + Text( + stringResource(R.string.namecoin_pin_cert_body, pending.serverHost), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pending.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pending.pem) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_accept)) + } + }, + dismissButton = { + TextButton(onClick = { + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_reject)) + } + }, + ) + } + Column { // ── Test button ──────────────────────────────────────── Button( @@ -208,20 +272,34 @@ private fun TestConnectionSection( if (!isTesting) { isTesting = true testResults = emptyList() + pendingCerts = emptyList() scope.launch { val results = mutableListOf() + val newCerts = mutableListOf() for (server in servers) { val result = onTestServer(server) results.add(result) testResults = results.toList() - // TOFU: auto-pin cert from successful connections + // Collect certs for user confirmation (not auto-pinned) val pem = result.serverCertPem - if (result.success && pem != null) { - onPinCert(pem) + val fp = result.certFingerprint + if (result.success && pem != null && fp != null) { + newCerts.add( + PendingCertPin( + serverHost = "${server.host}:${server.port}", + fingerprint = fp, + pem = pem, + ), + ) } } lastTestTimestamp = System.currentTimeMillis() isTesting = false + // Show confirmation dialog for each new cert + if (newCerts.isNotEmpty()) { + pendingCerts = newCerts + confirmingCert = newCerts.first() + } } } }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1f10ced90..38c3c142f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1853,6 +1853,10 @@ TLS Info No test run yet %dms + Trust Server Certificate? + The server %1$s presented a certificate not yet in your trust store. Verify the fingerprint below matches what the server operator published, then choose whether to trust it for future connections. + Trust + Reject Relay Sync Relay Sync Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data. diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index fc831d0e2..5119a6eb7 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -611,13 +611,39 @@ class ElectrumXClient( // via the onion address, making TLS cert verification redundant. val sslFactory = if (server.host.endsWith(".onion")) { - onionSslFactory() + // .onion addresses: prefer the pinned factory (the .onion server's + // cert is already in PINNED_ELECTRUMX_CERTS). Fall back to trust-all + // only if the pinned handshake fails — this keeps compatibility with + // .onion servers whose certs aren't pinned yet, while avoiding a + // blanket trust-all that hardened TLS stacks (GrapheneOS, Samsung + // Knox) may reject at the Conscrypt/BoringSSL layer. + cachedPinnedSslFactory() } else if (server.trustAllCerts) { cachedPinnedSslFactory() } else { SSLSocketFactory.getDefault() as SSLSocketFactory } - val sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true) + val sslSocket: Socket + try { + sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true) + } catch (e: javax.net.ssl.SSLHandshakeException) { + if (server.host.endsWith(".onion")) { + // Pinned factory failed for .onion — fall back to trust-all. + // This is safe: Tor provides E2E authentication via the onion + // address, and the proxied socket bypasses Knox/GrapheneOS + // trust-all rejection in practice. + val fallbackSocket = onionSslFactory().createSocket(baseSocket, server.host, server.port, true) + if (fallbackSocket is javax.net.ssl.SSLSocket) { + val supported = fallbackSocket.supportedProtocols + val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (modern.isNotEmpty()) { + fallbackSocket.enabledProtocols = modern.toTypedArray() + } + } + return fallbackSocket + } + throw e + } // Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS) // may negotiate TLS 1.0/1.1 by default for raw socket upgrades. @@ -633,15 +659,20 @@ class ElectrumXClient( } /** - * SSLSocketFactory for .onion addresses. + * Fallback SSLSocketFactory for .onion addresses when the pinned + * factory fails (e.g. cert rotated, unknown .onion server). * - * Tor hidden services are authenticated by their onion address (the - * public key hash), so TLS certificate verification is redundant. - * We use a trust-all factory here — this is safe because: + * Only used as a last resort after cachedPinnedSslFactory() throws + * SSLHandshakeException. This is safe because: * 1. The connection is already end-to-end encrypted by Tor. - * 2. The onion address IS the server's identity proof. - * 3. Samsung Knox's trust-all rejection doesn't apply to proxied - * sockets routed through Tor's SOCKS interface. + * 2. The onion address IS the server's identity proof (public key hash). + * 3. Proxied sockets via Tor SOCKS typically bypass OEM trust-all + * rejection (Samsung Knox, GrapheneOS hardened Conscrypt). + * + * Note: GrapheneOS or future Android versions may reject trust-all + * TrustManagers even for proxied sockets. If this fallback stops + * working, the .onion server's cert should be added to + * PINNED_ELECTRUMX_CERTS (it's already there for the known server). */ private fun onionSslFactory(): SSLSocketFactory { val trustAll = From 2e02edc3333c40e70fa82ed801264835d817ce4e Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 06:33:43 +1100 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=20rename=20trustAllCerts=20?= =?UTF-8?q?=E2=86=92=20usePinnedTrustStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field no longer means 'trust all certificates' — since the Samsung One UI 7 fix, it means 'use the pinned trust store (hardcoded + TOFU-pinned certs + system CAs) instead of system-only trust.' The old name was actively misleading and could cause a future contributor to interpret the flag literally, potentially reintroducing the trust-all pattern that Samsung Knox and GrapheneOS reject. Renamed across all 4 files: - ElectrumxServer.kt: field definition + updated KDoc - ElectrumXClient.kt: createSocket() usage + comments - NamecoinSettings.kt: parseServerString() usage + comments - NamecoinSettingsTest.kt: test assertions --- .../service/namecoin/NamecoinSettings.kt | 14 +++++------- .../service/namecoin/NamecoinSettingsTest.kt | 4 ++-- .../namecoin/ElectrumXServer.kt | 22 ++++++++++++------- .../namecoin/ElectrumXClient.kt | 14 +++++------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 0f372e577..1a859848c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -66,7 +66,7 @@ data class NamecoinSettings( * TLS is the default protocol. Append `:tcp` for plaintext * (useful for `.onion` addresses and local servers). * - * `.onion` addresses automatically get `trustAllCerts = true` + * `.onion` addresses automatically get `usePinnedTrustStore = true` * since certificate verification is meaningless over Tor. */ fun parseServerString(s: String): ElectrumxServer? { @@ -77,17 +77,15 @@ data class NamecoinSettings( if (host.isEmpty() || port <= 0 || port > 65535) return null val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" val isOnion = host.endsWith(".onion") - // All custom servers set trustAllCerts=true, which (despite the - // legacy name) means "use the pinned trust store" rather than - // "trust all certificates". ElectrumX servers almost universally - // use self-signed certs, so we route them through our pinned - // SSLSocketFactory (hardcoded defaults + TOFU-pinned certs). - // TODO: rename trustAllCerts → usePinnedTrustStore for clarity. + // All custom servers use the pinned trust store. ElectrumX + // servers almost universally use self-signed certs, so we + // route them through our pinned SSLSocketFactory (hardcoded + // defaults + TOFU-pinned certs + system CAs). return ElectrumxServer( host = host, port = port, useSsl = useSsl, - trustAllCerts = true, + usePinnedTrustStore = true, ) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt index 4eb495af6..661caca6e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt @@ -56,7 +56,7 @@ class NamecoinSettingsTest { assertEquals("abc123def.onion", s!!.host) assertEquals(50001, s.port) assertFalse(s.useSsl) - assertTrue(s.trustAllCerts) + assertTrue(s.usePinnedTrustStore) } @Test @@ -131,7 +131,7 @@ class NamecoinSettingsTest { assertTrue(servers[0].useSsl) assertEquals("server2.onion", servers[1].host) assertFalse(servers[1].useSsl) - assertTrue(servers[1].trustAllCerts) + assertTrue(servers[1].usePinnedTrustStore) } @Test diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index f683894f8..4c6c96937 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -44,8 +44,14 @@ data class ElectrumxServer( val host: String, val port: Int, val useSsl: Boolean = true, - /** If true, accept any certificate (self-signed, expired, etc.) */ - val trustAllCerts: Boolean = false, + /** + * If true, use the pinned trust store (hardcoded + TOFU-pinned certs + * plus system CAs) instead of the default system-only trust store. + * + * Required for ElectrumX servers that use self-signed certificates, + * which is the norm for the Namecoin ElectrumX ecosystem. + */ + val usePinnedTrustStore: Boolean = false, ) /** @@ -90,9 +96,9 @@ data class ServerTestResult( /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_ELECTRUMX_SERVERS = listOf( - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), - ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("46.229.238.187", 57002, useSsl = true, usePinnedTrustStore = true), ) /** Tor-preferred server list: onion primary, clearnet fallback. */ @@ -102,8 +108,8 @@ val TOR_ELECTRUMX_SERVERS = "i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion", 50002, useSsl = true, - trustAllCerts = true, + usePinnedTrustStore = true, ), - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), ) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 5119a6eb7..f8e0e3152 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -601,14 +601,10 @@ class ElectrumXClient( if (!server.useSsl) return baseSocket // Upgrade to TLS over the already-connected (possibly proxied) socket. - // When the server uses a self-signed certificate (trustAllCerts flag), - // we use a pinned trust store that contains the known ElectrumX server - // certs. This is required because Samsung One UI 7 (Android 16) silently - // rejects connections that use a no-op "trust-all" X509TrustManager. - // - // Exception: .onion addresses bypass cert pinning entirely — the Tor - // hidden service protocol already provides end-to-end authentication - // via the onion address, making TLS cert verification redundant. + // When usePinnedTrustStore is set, we use a pinned trust store that + // contains the known ElectrumX server certs plus system CAs. This is + // required because Samsung One UI 7 (Android 16) and GrapheneOS + // reject connections that use a no-op "trust-all" X509TrustManager. val sslFactory = if (server.host.endsWith(".onion")) { // .onion addresses: prefer the pinned factory (the .onion server's @@ -618,7 +614,7 @@ class ElectrumXClient( // blanket trust-all that hardened TLS stacks (GrapheneOS, Samsung // Knox) may reject at the Conscrypt/BoringSSL layer. cachedPinnedSslFactory() - } else if (server.trustAllCerts) { + } else if (server.usePinnedTrustStore) { cachedPinnedSslFactory() } else { SSLSocketFactory.getDefault() as SSLSocketFactory From 9db928395285b28ff656e904cc9f299e3475aa81 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 25 Mar 2026 20:07:46 +0000 Subject: [PATCH 08/17] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index b35e2d06a..95c094a36 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -66,7 +66,7 @@ Zapy Liczba wyświetleń Powtórz - powtórzono + wpis powtórzono edytowano edytuj #%1$s oryginalny @@ -1333,6 +1333,7 @@ Hashtagi Społeczności Listy + Transmitery Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość From 618edf9dd5e2c36ad952e06a4e193e306b524abb Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 25 Mar 2026 20:17:28 +0000 Subject: [PATCH 09/17] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index b35e2d06a..95c094a36 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -66,7 +66,7 @@ Zapy Liczba wyświetleń Powtórz - powtórzono + wpis powtórzono edytowano edytuj #%1$s oryginalny @@ -1333,6 +1333,7 @@ Hashtagi Społeczności Listy + Transmitery Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość From 0d83af89129fcb2d7e9fee0bcb1e96b906f8f3bd Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 07:12:33 +1100 Subject: [PATCH 10/17] feat: resolve bare .bit domains and show Namecoin resolution status in UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the existing NIP-05 resolution gate in UserSuggestionState and SearchBarViewModel to also accept bare .bit domains and d//id/ Namecoin identifiers. A synthesised Nip05Id is passed to the existing nip05Client.get() path — which already routes .bit to the NamecoinNameResolver — so no separate resolution logic is needed. Add NamecoinResolutionState (Idle/Resolving/Resolved/Error) as a StateFlow so the UI can show progress. ImportFollowListSelectUserScreen and SearchScreen display an animated status banner (spinner while resolving, chain emoji on success, warning on error) and label Namecoin-resolved profiles in the results list. --- .../userSuggestions/UserSuggestionState.kt | 76 +++++++++-- .../ImportFollowListSelectUserScreen.kt | 121 +++++++++++++++++- .../loggedIn/search/SearchBarViewModel.kt | 58 +++++++-- .../ui/screen/loggedIn/search/SearchScreen.kt | 116 ++++++++++++++++- 4 files changed, 353 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4fc579daa..9695346a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub @@ -57,6 +58,44 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** UI state for Namecoin resolution progress. */ +sealed class NamecoinResolutionState { + data object Idle : NamecoinResolutionState() + + data object Resolving : NamecoinResolutionState() + + data class Resolved( + val user: User, + val namecoinName: String, + ) : NamecoinResolutionState() + + data class Error( + val message: String, + ) : NamecoinResolutionState() +} + +/** Returns a [Nip05Id] for identifiers that should go through NIP-05 / Namecoin resolution. */ +private fun toNip05IdOrNull(prefix: String): Nip05Id? = + when { + prefix.contains('@') -> { + Nip05Id.parse(prefix) + } + + NamecoinNameResolver.isNamecoinIdentifier(prefix) -> { + if (prefix.endsWith(".bit", ignoreCase = true)) { + // Bare .bit domain → synthesize _@domain.bit so Nip05Client routes to Namecoin + Nip05Id("_", prefix.lowercase()) + } else { + // d/ or id/ — wrap as NIP-05 so it reaches the resolver + Nip05Id("_", prefix.lowercase()) + } + } + + else -> { + null + } + } + @Stable class UserSuggestionState( val account: Account, @@ -66,6 +105,9 @@ class UserSuggestionState( val currentWord = MutableStateFlow("") val searchDataSourceState = SearchQueryState(MutableStateFlow(""), account) + /** Tracks Namecoin resolution status for the UI. */ + val namecoinState = MutableStateFlow(NamecoinResolutionState.Idle) + @OptIn(FlowPreview::class) val searchTerm = currentWord @@ -82,23 +124,38 @@ class UserSuggestionState( .map(::userSearchTermOrNull) .map { prefix -> if (prefix != null) { - if (prefix.contains('@')) { - runCatching { - Nip05Id.parse(prefix)?.let { nip05 -> + val nip05 = toNip05IdOrNull(prefix) + if (nip05 != null) { + val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue()) + if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving) + + val user = + runCatching { nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { + val u = account.cache.checkGetOrCreateUser(info.pubkey) + if (u != null) { info.relays.forEach { it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) + account.cache.relayHints.addKey(u.pubkey(), relay) } } } - user + u } + }.getOrNull() + + if (isNamecoin) { + if (user != null) { + namecoinState.emit(NamecoinResolutionState.Resolved(user, prefix)) + } else { + namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $prefix via Namecoin")) } - }.getOrNull() + } else { + namecoinState.emit(NamecoinResolutionState.Idle) + } + user } else if (prefix.startsWithAny(userUriPrefixes)) { + namecoinState.emit(NamecoinResolutionState.Idle) runCatching { Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed -> when (parsed) { @@ -125,11 +182,14 @@ class UserSuggestionState( } }.getOrNull() } else if (prefix.length == 64 && Hex.isHex64(prefix)) { + namecoinState.emit(NamecoinResolutionState.Idle) account.cache.getOrCreateUser(prefix) } else { + namecoinState.emit(NamecoinResolutionState.Idle) null } } else { + namecoinState.emit(NamecoinResolutionState.Idle) null } }.flowOn(Dispatchers.IO) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt index 9b0c9286c..c53c8b889 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -37,6 +42,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -67,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDa import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -100,6 +107,10 @@ class ImportFollowListSelectUserViewModel( userSuggestions.results .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val namecoinState = + userSuggestions.namecoinState + .stateIn(viewModelScope, SharingStarted.Eagerly, NamecoinResolutionState.Idle) + class Factory( val account: Account, val nip05Client: INip05Client, @@ -166,6 +177,10 @@ private fun InputSelectUserBody( supportingText = { Text(stringRes(R.string.supports_npub_nip_05_hex_and_namecoin_bit_d_id)) }, ) + Spacer(Modifier.height(4.dp)) + + NamecoinStatusBanner(viewModel) + Spacer(Modifier.height(8.dp)) CustomShowUserSuggestionList( @@ -185,6 +200,88 @@ private fun InputSelectUserBody( } } +@Composable +private fun NamecoinStatusBanner(viewModel: ImportFollowListSelectUserViewModel) { + val ncState by viewModel.namecoinState.collectAsStateWithLifecycle() + + AnimatedVisibility( + visible = ncState !is NamecoinResolutionState.Idle, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + when (val state = ncState) { + is NamecoinResolutionState.Resolving -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Resolving via Namecoin blockchain\u2026", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + is NamecoinResolutionState.Resolved -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u26D3\uFE0F", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.width(6.dp)) + Text( + "Resolved via Namecoin: ${state.namecoinName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + } + } + + is NamecoinResolutionState.Error -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u26A0\uFE0F", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.width(6.dp)) + Text( + state.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + is NamecoinResolutionState.Idle -> { + // nothing + } + } + } +} + @Composable private fun ImportHeader() { Column { @@ -244,6 +341,7 @@ fun CustomWatchResponses( modifier: Modifier = Modifier, ) { val suggestions by viewModel.results.collectAsStateWithLifecycle() + val ncState by viewModel.namecoinState.collectAsStateWithLifecycle() if (suggestions.isNotEmpty()) { LazyColumn( @@ -252,7 +350,28 @@ fun CustomWatchResponses( state = viewModel.listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - UserLine(item, accountViewModel) { onSelect(item) } + val isNamecoinResult = + ncState is NamecoinResolutionState.Resolved && + (ncState as NamecoinResolutionState.Resolved).user == item + + if (isNamecoinResult) { + Column { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u26D3\uFE0F Namecoin result", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + } + UserLine(item, accountViewModel) { onSelect(item) } + } + } else { + UserLine(item, accountViewModel) { onSelect(item) } + } HorizontalDivider( thickness = DividerThickness, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index d29bb7037..58bcb90c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile @@ -66,6 +68,26 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +/** Returns a [Nip05Id] for identifiers that should go through NIP-05 / Namecoin resolution. */ +private fun toNip05IdOrNull(term: String): Nip05Id? = + when { + term.contains('@') -> { + Nip05Id.parse(term) + } + + NamecoinNameResolver.isNamecoinIdentifier(term) -> { + if (term.endsWith(".bit", ignoreCase = true)) { + Nip05Id("_", term.lowercase()) + } else { + Nip05Id("_", term.lowercase()) + } + } + + else -> { + null + } + } + @Stable @OptIn(FlowPreview::class) class SearchBarViewModel( @@ -79,6 +101,9 @@ class SearchBarViewModel( val invalidations = MutableStateFlow(0) val searchValueFlow = MutableStateFlow("") + /** Tracks Namecoin resolution status for the UI. */ + val namecoinState = MutableStateFlow(NamecoinResolutionState.Idle) + val searchTerm = searchValueFlow .debounce(300) @@ -95,23 +120,38 @@ class SearchBarViewModel( searchTerm .debounce(400) .mapLatest { term -> - if (term.contains('@')) { - runCatching { - Nip05Id.parse(term)?.let { nip05 -> + val nip05 = toNip05IdOrNull(term) + if (nip05 != null) { + val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue()) + if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving) + + val user = + runCatching { nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { + val u = account.cache.checkGetOrCreateUser(info.pubkey) + if (u != null) { info.relays.forEach { it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) + account.cache.relayHints.addKey(u.pubkey(), relay) } } } - user + u } + }.getOrNull() + + if (isNamecoin) { + if (user != null) { + namecoinState.emit(NamecoinResolutionState.Resolved(user, term)) + } else { + namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $term via Namecoin")) } - }.getOrNull() + } else { + namecoinState.emit(NamecoinResolutionState.Idle) + } + user } else if (term.startsWithAny(userUriPrefixes)) { + namecoinState.emit(NamecoinResolutionState.Idle) runCatching { Nip19Parser.uriToRoute(term)?.entity?.let { parsed -> when (parsed) { @@ -138,8 +178,10 @@ class SearchBarViewModel( } }.getOrNull() } else if (term.length == 64 && Hex.isHex64(term)) { + namecoinState.emit(NamecoinResolutionState.Idle) account.cache.getOrCreateUser(term) } else { + namecoinState.emit(NamecoinResolutionState.Idle) null } }.flowOn(Dispatchers.IO) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 68d952c01..e626a094c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -20,20 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable 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.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -67,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow @@ -160,7 +170,10 @@ private fun SearchBar( } } - SearchTextField(searchBarViewModel, Modifier.statusBarsPadding()) + Column { + SearchTextField(searchBarViewModel, Modifier.statusBarsPadding()) + SearchNamecoinStatusBanner(searchBarViewModel) + } } @Composable @@ -216,6 +229,88 @@ private fun SearchTextField( } } +@Composable +private fun SearchNamecoinStatusBanner(searchBarViewModel: SearchBarViewModel) { + val ncState by searchBarViewModel.namecoinState.collectAsStateWithLifecycle() + + AnimatedVisibility( + visible = ncState !is NamecoinResolutionState.Idle, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + when (val state = ncState) { + is NamecoinResolutionState.Resolving -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Resolving via Namecoin blockchain\u2026", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + is NamecoinResolutionState.Resolved -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u26D3\uFE0F", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.width(6.dp)) + Text( + "Resolved via Namecoin: ${state.namecoinName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + } + } + + is NamecoinResolutionState.Error -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u26A0\uFE0F", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.width(6.dp)) + Text( + state.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + is NamecoinResolutionState.Idle -> { + // nothing + } + } + } +} + @Composable private fun DisplaySearchResults( searchBarViewModel: SearchBarViewModel, @@ -233,6 +328,7 @@ private fun DisplaySearchResults( val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() + val ncState by searchBarViewModel.namecoinState.collectAsStateWithLifecycle() LazyColumn( modifier = Modifier.fillMaxHeight(), @@ -255,6 +351,24 @@ private fun DisplaySearchResults( users, key = { _, item -> "u" + item.pubkeyHex }, ) { _, item -> + val isNamecoinResult = + ncState is NamecoinResolutionState.Resolved && + (ncState as NamecoinResolutionState.Resolved).user == item + + if (isNamecoinResult) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u26D3\uFE0F Namecoin result", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + } + } + UserCompose(item, accountViewModel = accountViewModel, nav = nav) HorizontalDivider( From 67fd48c5b8e7ef2e7cdfc120a0a127555e119ee0 Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 08:43:39 +1100 Subject: [PATCH 11/17] refactor: simplify .bit search resolution per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address @vitorpamplona's review comments on the last commit: 1. Remove NamecoinResolutionState sealed class and status banners — the existing NIP-05 resolver already handles .bit, no need for separate Namecoin-specific UI state (spinner, chain emoji, error) 2. Remove separate toNip05IdOrNull() resolution path — widen the existing `if (term.contains('@'))` gate in both SearchBarViewModel and UserSuggestionState to also accept bare .bit domains (synthesize _@domain.bit → existing NIP-05 flow) 3. Remove d/ and id/ identifier support from search — per feedback, only NIP-05 style resolution (.bit) in search 4. Remove special Namecoin result rendering in ImportFollowList — just show UserLine for all results, no separation needed 5. Keep a lightweight 'Namecoin' label on resolved users in search — namecoinResolvedUser StateFlow tracks the .bit-resolved user, shown as a small label above the UserCompose row Net: -405 lines, +50 lines across 6 files. --- .../userSuggestions/ShowUserSuggestionList.kt | 60 +-------- .../userSuggestions/UserSuggestionState.kt | 89 +++---------- .../ImportFollowListSelectUserScreen.kt | 121 +----------------- .../loggedIn/search/SearchBarViewModel.kt | 62 +++------ .../ui/screen/loggedIn/search/SearchScreen.kt | 121 ++---------------- amethyst/src/main/res/values/strings.xml | 2 +- 6 files changed, 50 insertions(+), 405 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index 7a0ae4ef3..37c1ad128 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -30,33 +30,23 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -155,51 +145,3 @@ fun UserLine( }, ) } - -@Composable -private fun WatchAndDisplayNip05Row( - user: User, - accountViewModel: AccountViewModel, -) { - val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() - - when (val nip05State = nip05StateMetadata) { - is Nip05State.Exists -> { - NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel) - } - - else -> { - Text( - text = user.pubkeyDisplayHex(), - fontSize = Font14SP, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -private fun NonClickableObserveAndDisplayNIP05( - nip05State: Nip05State.Exists, - accountViewModel: AccountViewModel, -) { - if (nip05State.nip05.name != "_") { - Text( - text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) }, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.nip05, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel) - - Text( - text = nip05State.nip05.domain, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), - maxLines = 1, - overflow = TextOverflow.Visible, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 9695346a7..4adc385d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id -import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub @@ -58,44 +57,6 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) -/** UI state for Namecoin resolution progress. */ -sealed class NamecoinResolutionState { - data object Idle : NamecoinResolutionState() - - data object Resolving : NamecoinResolutionState() - - data class Resolved( - val user: User, - val namecoinName: String, - ) : NamecoinResolutionState() - - data class Error( - val message: String, - ) : NamecoinResolutionState() -} - -/** Returns a [Nip05Id] for identifiers that should go through NIP-05 / Namecoin resolution. */ -private fun toNip05IdOrNull(prefix: String): Nip05Id? = - when { - prefix.contains('@') -> { - Nip05Id.parse(prefix) - } - - NamecoinNameResolver.isNamecoinIdentifier(prefix) -> { - if (prefix.endsWith(".bit", ignoreCase = true)) { - // Bare .bit domain → synthesize _@domain.bit so Nip05Client routes to Namecoin - Nip05Id("_", prefix.lowercase()) - } else { - // d/ or id/ — wrap as NIP-05 so it reaches the resolver - Nip05Id("_", prefix.lowercase()) - } - } - - else -> { - null - } - } - @Stable class UserSuggestionState( val account: Account, @@ -105,9 +66,6 @@ class UserSuggestionState( val currentWord = MutableStateFlow("") val searchDataSourceState = SearchQueryState(MutableStateFlow(""), account) - /** Tracks Namecoin resolution status for the UI. */ - val namecoinState = MutableStateFlow(NamecoinResolutionState.Idle) - @OptIn(FlowPreview::class) val searchTerm = currentWord @@ -124,38 +82,30 @@ class UserSuggestionState( .map(::userSearchTermOrNull) .map { prefix -> if (prefix != null) { - val nip05 = toNip05IdOrNull(prefix) + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (prefix.contains('@')) { + Nip05Id.parse(prefix) + } else if (prefix.endsWith(".bit", ignoreCase = true)) { + Nip05Id("_", prefix.lowercase()) + } else { + null + } if (nip05 != null) { - val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue()) - if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving) - - val user = - runCatching { - nip05Client.get(nip05)?.let { info -> - val u = account.cache.checkGetOrCreateUser(info.pubkey) - if (u != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(u.pubkey(), relay) - } + runCatching { + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - u } - }.getOrNull() - - if (isNamecoin) { - if (user != null) { - namecoinState.emit(NamecoinResolutionState.Resolved(user, prefix)) - } else { - namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $prefix via Namecoin")) + user } - } else { - namecoinState.emit(NamecoinResolutionState.Idle) - } - user + }.getOrNull() } else if (prefix.startsWithAny(userUriPrefixes)) { - namecoinState.emit(NamecoinResolutionState.Idle) runCatching { Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed -> when (parsed) { @@ -182,14 +132,11 @@ class UserSuggestionState( } }.getOrNull() } else if (prefix.length == 64 && Hex.isHex64(prefix)) { - namecoinState.emit(NamecoinResolutionState.Idle) account.cache.getOrCreateUser(prefix) } else { - namecoinState.emit(NamecoinResolutionState.Idle) null } } else { - namecoinState.emit(NamecoinResolutionState.Idle) null } }.flowOn(Dispatchers.IO) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt index c53c8b889..9b0c9286c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt @@ -20,11 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -42,7 +37,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -73,7 +67,6 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDa import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -107,10 +100,6 @@ class ImportFollowListSelectUserViewModel( userSuggestions.results .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - val namecoinState = - userSuggestions.namecoinState - .stateIn(viewModelScope, SharingStarted.Eagerly, NamecoinResolutionState.Idle) - class Factory( val account: Account, val nip05Client: INip05Client, @@ -177,10 +166,6 @@ private fun InputSelectUserBody( supportingText = { Text(stringRes(R.string.supports_npub_nip_05_hex_and_namecoin_bit_d_id)) }, ) - Spacer(Modifier.height(4.dp)) - - NamecoinStatusBanner(viewModel) - Spacer(Modifier.height(8.dp)) CustomShowUserSuggestionList( @@ -200,88 +185,6 @@ private fun InputSelectUserBody( } } -@Composable -private fun NamecoinStatusBanner(viewModel: ImportFollowListSelectUserViewModel) { - val ncState by viewModel.namecoinState.collectAsStateWithLifecycle() - - AnimatedVisibility( - visible = ncState !is NamecoinResolutionState.Idle, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - when (val state = ncState) { - is NamecoinResolutionState.Resolving -> { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Resolving via Namecoin blockchain\u2026", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - - is NamecoinResolutionState.Resolved -> { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "\u26D3\uFE0F", - style = MaterialTheme.typography.bodySmall, - ) - Spacer(Modifier.width(6.dp)) - Text( - "Resolved via Namecoin: ${state.namecoinName}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } - } - - is NamecoinResolutionState.Error -> { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "\u26A0\uFE0F", - style = MaterialTheme.typography.bodySmall, - ) - Spacer(Modifier.width(6.dp)) - Text( - state.message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - - is NamecoinResolutionState.Idle -> { - // nothing - } - } - } -} - @Composable private fun ImportHeader() { Column { @@ -341,7 +244,6 @@ fun CustomWatchResponses( modifier: Modifier = Modifier, ) { val suggestions by viewModel.results.collectAsStateWithLifecycle() - val ncState by viewModel.namecoinState.collectAsStateWithLifecycle() if (suggestions.isNotEmpty()) { LazyColumn( @@ -350,28 +252,7 @@ fun CustomWatchResponses( state = viewModel.listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - val isNamecoinResult = - ncState is NamecoinResolutionState.Resolved && - (ncState as NamecoinResolutionState.Resolved).user == item - - if (isNamecoinResult) { - Column { - Row( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "\u26D3\uFE0F Namecoin result", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } - UserLine(item, accountViewModel) { onSelect(item) } - } - } else { - UserLine(item, accountViewModel) { onSelect(item) } - } + UserLine(item, accountViewModel) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 58bcb90c2..0f1e163d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -44,7 +43,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id -import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile @@ -59,6 +57,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -68,26 +67,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -/** Returns a [Nip05Id] for identifiers that should go through NIP-05 / Namecoin resolution. */ -private fun toNip05IdOrNull(term: String): Nip05Id? = - when { - term.contains('@') -> { - Nip05Id.parse(term) - } - - NamecoinNameResolver.isNamecoinIdentifier(term) -> { - if (term.endsWith(".bit", ignoreCase = true)) { - Nip05Id("_", term.lowercase()) - } else { - Nip05Id("_", term.lowercase()) - } - } - - else -> { - null - } - } - @Stable @OptIn(FlowPreview::class) class SearchBarViewModel( @@ -101,9 +80,6 @@ class SearchBarViewModel( val invalidations = MutableStateFlow(0) val searchValueFlow = MutableStateFlow("") - /** Tracks Namecoin resolution status for the UI. */ - val namecoinState = MutableStateFlow(NamecoinResolutionState.Idle) - val searchTerm = searchValueFlow .debounce(300) @@ -115,16 +91,27 @@ class SearchBarViewModel( val listState: LazyListState = LazyListState(0, 0) + /** The user resolved via Namecoin (.bit), if any, for the current search. */ + private val _namecoinResolvedUser = MutableStateFlow(null) + val namecoinResolvedUser: StateFlow = _namecoinResolvedUser + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val directNip05Resolver: Flow = searchTerm .debounce(400) .mapLatest { term -> - val nip05 = toNip05IdOrNull(term) + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (term.contains('@')) { + Nip05Id.parse(term) + } else if (term.endsWith(".bit", ignoreCase = true)) { + // Bare .bit domain → synthesize _@domain.bit + Nip05Id("_", term.lowercase()) + } else { + null + } + val isNamecoin = nip05 != null && nip05.domain.endsWith(".bit", ignoreCase = true) if (nip05 != null) { - val isNamecoin = NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue()) - if (isNamecoin) namecoinState.emit(NamecoinResolutionState.Resolving) - val user = runCatching { nip05Client.get(nip05)?.let { info -> @@ -139,19 +126,10 @@ class SearchBarViewModel( u } }.getOrNull() - - if (isNamecoin) { - if (user != null) { - namecoinState.emit(NamecoinResolutionState.Resolved(user, term)) - } else { - namecoinState.emit(NamecoinResolutionState.Error("Could not resolve $term via Namecoin")) - } - } else { - namecoinState.emit(NamecoinResolutionState.Idle) - } + _namecoinResolvedUser.value = if (isNamecoin) user else null user } else if (term.startsWithAny(userUriPrefixes)) { - namecoinState.emit(NamecoinResolutionState.Idle) + _namecoinResolvedUser.value = null runCatching { Nip19Parser.uriToRoute(term)?.entity?.let { parsed -> when (parsed) { @@ -178,10 +156,10 @@ class SearchBarViewModel( } }.getOrNull() } else if (term.length == 64 && Hex.isHex64(term)) { - namecoinState.emit(NamecoinResolutionState.Idle) + _namecoinResolvedUser.value = null account.cache.getOrCreateUser(term) } else { - namecoinState.emit(NamecoinResolutionState.Idle) + _namecoinResolvedUser.value = null null } }.flowOn(Dispatchers.IO) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index e626a094c..17f68094a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -20,29 +20,20 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable 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.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -76,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.NamecoinResolutionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow @@ -170,10 +160,7 @@ private fun SearchBar( } } - Column { - SearchTextField(searchBarViewModel, Modifier.statusBarsPadding()) - SearchNamecoinStatusBanner(searchBarViewModel) - } + SearchTextField(searchBarViewModel, Modifier.statusBarsPadding()) } @Composable @@ -229,88 +216,6 @@ private fun SearchTextField( } } -@Composable -private fun SearchNamecoinStatusBanner(searchBarViewModel: SearchBarViewModel) { - val ncState by searchBarViewModel.namecoinState.collectAsStateWithLifecycle() - - AnimatedVisibility( - visible = ncState !is NamecoinResolutionState.Idle, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - when (val state = ncState) { - is NamecoinResolutionState.Resolving -> { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Resolving via Namecoin blockchain\u2026", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - - is NamecoinResolutionState.Resolved -> { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "\u26D3\uFE0F", - style = MaterialTheme.typography.bodySmall, - ) - Spacer(Modifier.width(6.dp)) - Text( - "Resolved via Namecoin: ${state.namecoinName}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } - } - - is NamecoinResolutionState.Error -> { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "\u26A0\uFE0F", - style = MaterialTheme.typography.bodySmall, - ) - Spacer(Modifier.width(6.dp)) - Text( - state.message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - - is NamecoinResolutionState.Idle -> { - // nothing - } - } - } -} - @Composable private fun DisplaySearchResults( searchBarViewModel: SearchBarViewModel, @@ -324,11 +229,11 @@ private fun DisplaySearchResults( val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() + val namecoinUser by searchBarViewModel.namecoinResolvedUser.collectAsStateWithLifecycle() val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() - val ncState by searchBarViewModel.namecoinState.collectAsStateWithLifecycle() LazyColumn( modifier = Modifier.fillMaxHeight(), @@ -351,22 +256,14 @@ private fun DisplaySearchResults( users, key = { _, item -> "u" + item.pubkeyHex }, ) { _, item -> - val isNamecoinResult = - ncState is NamecoinResolutionState.Resolved && - (ncState as NamecoinResolutionState.Resolved).user == item - - if (isNamecoinResult) { - Row( + if (namecoinUser == item) { + Text( + "\u26D3\uFE0F Namecoin", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "\u26D3\uFE0F Namecoin result", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } + ) } UserCompose(item, accountViewModel = accountViewModel, nav = nav) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 38c3c142f..ec51b889a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1820,7 +1820,7 @@ Select Users to Follow Profile to import from search, npub1…, alice@example.com - Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/) + Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit) Look Up Follow List Tip %1$d accounts found From 1ca904b415ff2660a672519f530ae065d3bd46a5 Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 09:37:56 +1100 Subject: [PATCH 12/17] refactor: remove Namecoin label from search results per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove namecoinResolvedUser StateFlow and the chain-emoji label from search results. .bit resolution still works through the existing NIP-05 path — just no special UI decoration. --- .../loggedIn/search/SearchBarViewModel.kt | 32 ++++++------------- .../ui/screen/loggedIn/search/SearchScreen.kt | 11 ------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 0f1e163d5..02045f705 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -57,7 +57,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -91,10 +90,6 @@ class SearchBarViewModel( val listState: LazyListState = LazyListState(0, 0) - /** The user resolved via Namecoin (.bit), if any, for the current search. */ - private val _namecoinResolvedUser = MutableStateFlow(null) - val namecoinResolvedUser: StateFlow = _namecoinResolvedUser - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val directNip05Resolver: Flow = searchTerm @@ -110,26 +105,21 @@ class SearchBarViewModel( } else { null } - val isNamecoin = nip05 != null && nip05.domain.endsWith(".bit", ignoreCase = true) if (nip05 != null) { - val user = - runCatching { - nip05Client.get(nip05)?.let { info -> - val u = account.cache.checkGetOrCreateUser(info.pubkey) - if (u != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(u.pubkey(), relay) - } + runCatching { + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - u } - }.getOrNull() - _namecoinResolvedUser.value = if (isNamecoin) user else null - user + user + } + }.getOrNull() } else if (term.startsWithAny(userUriPrefixes)) { - _namecoinResolvedUser.value = null runCatching { Nip19Parser.uriToRoute(term)?.entity?.let { parsed -> when (parsed) { @@ -156,10 +146,8 @@ class SearchBarViewModel( } }.getOrNull() } else if (term.length == 64 && Hex.isHex64(term)) { - _namecoinResolvedUser.value = null account.cache.getOrCreateUser(term) } else { - _namecoinResolvedUser.value = null null } }.flowOn(Dispatchers.IO) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 17f68094a..68d952c01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -229,7 +229,6 @@ private fun DisplaySearchResults( val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val namecoinUser by searchBarViewModel.namecoinResolvedUser.collectAsStateWithLifecycle() val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() @@ -256,16 +255,6 @@ private fun DisplaySearchResults( users, key = { _, item -> "u" + item.pubkeyHex }, ) { _, item -> - if (namecoinUser == item) { - Text( - "\u26D3\uFE0F Namecoin", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - ) - } - UserCompose(item, accountViewModel = accountViewModel, nav = nav) HorizontalDivider( From e7cde1837138a98b9f35a714a10108ec3a14aebc Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 09:49:34 +1100 Subject: [PATCH 13/17] feat: show Namecoin label on .bit search results without ViewModel state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the label from the existing searchTerm — if it ends with .bit, show a chain-emoji label above each user result. No new StateFlow or ViewModel state management needed. --- .../ui/screen/loggedIn/search/SearchScreen.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 68d952c01..8e5e3b93a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -229,6 +229,8 @@ private fun DisplaySearchResults( val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() + val currentSearch by searchBarViewModel.searchTerm.collectAsStateWithLifecycle() + val isNamecoinSearch = currentSearch.endsWith(".bit", ignoreCase = true) val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() @@ -255,6 +257,16 @@ private fun DisplaySearchResults( users, key = { _, item -> "u" + item.pubkeyHex }, ) { _, item -> + if (isNamecoinSearch) { + Text( + "\u26D3\uFE0F Namecoin", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } + UserCompose(item, accountViewModel = accountViewModel, nav = nav) HorizontalDivider( From ebbcfa26fb689e9c672d2e0c0ae146f28a898873 Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 10:25:31 +1100 Subject: [PATCH 14/17] refactor: remove Namecoin label from search results Per review: .bit resolution is just NIP-05, no special labeling needed. --- .../ui/screen/loggedIn/search/SearchScreen.kt | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 8e5e3b93a..68d952c01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -229,8 +229,6 @@ private fun DisplaySearchResults( val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val currentSearch by searchBarViewModel.searchTerm.collectAsStateWithLifecycle() - val isNamecoinSearch = currentSearch.endsWith(".bit", ignoreCase = true) val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() @@ -257,16 +255,6 @@ private fun DisplaySearchResults( users, key = { _, item -> "u" + item.pubkeyHex }, ) { _, item -> - if (isNamecoinSearch) { - Text( - "\u26D3\uFE0F Namecoin", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - ) - } - UserCompose(item, accountViewModel = accountViewModel, nav = nav) HorizontalDivider( From 7293b0985b2e27a9155a171399c7a7051297678e Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 17:36:53 +1100 Subject: [PATCH 15/17] fix: restore local WatchAndDisplayNip05Row in ShowUserSuggestionList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local private WatchAndDisplayNip05Row uses NonClickableObserveAndDisplayNIP05 (plain text domain), while the shared one in ShowQRScreen uses the clickable ObserveAndDisplayNIP05. They are not the same function — restore the local copy to preserve the correct non-clickable behavior in user suggestions. --- .../userSuggestions/ShowUserSuggestionList.kt | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index 37c1ad128..7a0ae4ef3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -30,23 +30,33 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -145,3 +155,51 @@ fun UserLine( }, ) } + +@Composable +private fun WatchAndDisplayNip05Row( + user: User, + accountViewModel: AccountViewModel, +) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + when (val nip05State = nip05StateMetadata) { + is Nip05State.Exists -> { + NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel) + } + + else -> { + Text( + text = user.pubkeyDisplayHex(), + fontSize = Font14SP, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun NonClickableObserveAndDisplayNIP05( + nip05State: Nip05State.Exists, + accountViewModel: AccountViewModel, +) { + if (nip05State.nip05.name != "_") { + Text( + text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) }, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.nip05, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel) + + Text( + text = nip05State.nip05.domain, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, + ) +} From 17eea1dca9d5a3718e9694544eafe345f7d84e1d Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Mar 2026 11:52:44 +0100 Subject: [PATCH 16/17] update translations: CZ, DE, PT, SE --- .../src/main/res/values-cs-rCZ/strings.xml | 25 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 25 +++++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 25 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 25 +++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 7775e3628..f8ac69e5e 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1675,4 +1675,29 @@ Vždy Poslední synchronizace: %1$s Od poslední synchronizace + Tato odpověď bude odeslána z nové anonymní identity + Anonymní + Odeslat jako novou jednorázovou identitu. Váš účet nebude s touto odpovědí spojen. + Relé + Webová záložka + Více možností + Jedna možnost + Podepisovatel se choval neočekávaně + Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli. + Přidat webovou záložku + Smazat + Smazat tuto webovou záložku? + Popis + Krátký popis + Upravit webovou záložku + Otevřít URL + Uložit + Tagy (oddělené čárkou) + nostr, tech, blog + Název + Název záložky + URL + https://example.com + Webové záložky + Zatím žádné webové záložky. Klepněte na + pro přidání. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index fcf5a470e..3f09d850b 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1680,4 +1680,29 @@ anz der Bedingungen ist erforderlich Gesamter Zeitraum Letzte Synchronisierung: %1$s Seit letzter Synchronisierung + Diese Antwort wird von einer neuen anonymen Identität veröffentlicht + Anonym + Als neue Wegwerfidentität posten. Dein Konto wird nicht mit dieser Antwort verknüpft. + Relays + Web-Lesezeichen + Mehrfachauswahl + Einzelauswahl + Signierer hat sich unerwartet verhalten + Externer Signierer hat Daten zurückgegeben, die für die Anfrage ungewöhnlich sind. Es könnte ein Fehler in Amethyst oder im Signierer vorliegen. + Web-Lesezeichen hinzufügen + Löschen + Dieses Web-Lesezeichen löschen? + Beschreibung + Eine kurze Beschreibung + Web-Lesezeichen bearbeiten + URL öffnen + Speichern + Tags (kommagetrennt) + nostr, tech, blog + Titel + Lesezeichen-Titel + URL + https://example.com + Web-Lesezeichen + Noch keine Web-Lesezeichen. Tippe auf + um eines hinzuzufügen. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index e22277af2..4be2136bc 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1675,4 +1675,29 @@ Todo o período Última sincronização: %1$s Desde a última sincronização + Esta resposta será postada a partir de uma nova identidade anônima + Anônimo + Postar como uma nova identidade descartável. Sua conta não será vinculada a esta resposta. + Relays + Marcador Web + Múltipla escolha + Escolha única + Assinador se comportou de forma inesperada + O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador. + Adicionar Marcador Web + Excluir + Excluir este marcador web? + Descrição + Uma breve descrição + Editar Marcador Web + Abrir URL + Salvar + Tags (separadas por vírgula) + nostr, tech, blog + Título + Título do marcador + URL + https://example.com + Marcadores Web + Nenhum marcador web ainda. Toque em + para adicionar. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 777f8fa18..a958f969f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1674,4 +1674,29 @@ All tid Senaste synkronisering: %1$s Sedan senaste synkronisering + Detta svar kommer att publiceras från en ny anonym identitet + Anonym + Publicera som en ny engångsidentitet. Ditt konto kommer inte att kopplas till detta svar. + Reläer + Webbbokmärke + Flerval + Enkelt val + Signatären betedde sig oväntat + Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären. + Lägg till webbbokmärke + Radera + Radera detta webbbokmärke? + Beskrivning + En kort beskrivning + Redigera webbbokmärke + Öppna URL + Spara + Taggar (kommaseparerade) + nostr, tech, blog + Titel + Bokmärkets titel + URL + https://example.com + Webbbokmärken + Inga webbbokmärken ännu. Tryck på + för att lägga till. From b77e6c1b6c17675f3ee57e041e26db3fd40addf5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 26 Mar 2026 10:57:57 +0000 Subject: [PATCH 17/17] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 43 +++++++++---------- .../src/main/res/values-de-rDE/strings.xml | 42 ++++++++---------- .../src/main/res/values-pl-rPL/strings.xml | 17 ++++++++ .../src/main/res/values-pt-rBR/strings.xml | 42 ++++++++---------- .../src/main/res/values-sl-rSI/strings.xml | 39 ++++++++++++++--- .../src/main/res/values-sv-rSE/strings.xml | 43 +++++++++---------- .../src/main/res/values-zh-rCN/strings.xml | 40 ++++++++++++----- 7 files changed, 158 insertions(+), 108 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index f8ac69e5e..3f211ea2f 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -61,6 +61,8 @@ Podepisovatel neautorizoval dešifrování potřebné k provedení této operace. Aktivujte dešifrování NIP-44 ve své aplikaci pro podepisování a zkuste to znovu Podepisovatel nenalezen Byla aplikace pro podepisování odinstalována? Zkontrolujte, zda je aplikace nainstalována a obsahuje tento účet. Odhlaste se a přihlaste znovu, pokud se aplikace změnila. + Podepisovatel se choval neočekávaně + Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli. Zapy Počet zobrazení Zvýšení @@ -446,6 +448,8 @@ Maximální zaps Konsensus (0–100)% + Jedna možnost + Více možností Datum a čas ukončení ankety Anketa končí za %1$s Uzavřít po @@ -485,6 +489,9 @@ Příjemce a veřejnost neví, kdo platbu poslal Né Zap Žádná stopa v Nostr, pouze v Lightning + Anonymní + Odeslat jako novou jednorázovou identitu. Váš účet nebude s touto odpovědí spojen. + Tato odpověď bude odeslána z nové anonymní identity Souborový server Zvolte server pro nahrání tohoto souboru LnAddress nebo @Uživatel @@ -1324,6 +1331,7 @@ Hashtagy Komunity Seznamy + Relé Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1560,6 +1568,7 @@ Krátká videa Hlasová zpráva Hlasová odpověď + Webová záložka Wiki Začněte se skvělým feedem tím, že budete sledovat stejné lidi jako někdo, komu důvěřujete. Importovat seznam sledovaných @@ -1675,29 +1684,17 @@ Vždy Poslední synchronizace: %1$s Od poslední synchronizace - Tato odpověď bude odeslána z nové anonymní identity - Anonymní - Odeslat jako novou jednorázovou identitu. Váš účet nebude s touto odpovědí spojen. - Relé - Webová záložka - Více možností - Jedna možnost - Podepisovatel se choval neočekávaně - Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli. - Přidat webovou záložku - Smazat - Smazat tuto webovou záložku? - Popis - Krátký popis - Upravit webovou záložku - Otevřít URL - Uložit - Tagy (oddělené čárkou) - nostr, tech, blog - Název - Název záložky - URL - https://example.com Webové záložky Zatím žádné webové záložky. Klepněte na + pro přidání. + Přidat webovou záložku + Upravit webovou záložku + Název + Název záložky + Popis + Krátký popis + Tagy (oddělené čárkou) + Uložit + Smazat + Smazat tuto webovou záložku? + Otevřít URL diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 3f09d850b..1fa61af01 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -61,6 +61,8 @@ Der Signierer hat die erforderliche Entschlüsselung nicht autorisiert. Aktiviere NIP-44-Entschlüsselung in deiner Signierer-App und versuche es erneut Signierer nicht gefunden Wurde die Signierer-App deinstalliert? Überprüfe, ob sie installiert ist und dieses Konto enthält. Melde dich ab und wieder an, falls sich die App geändert hat. + Signierer hat sich unerwartet verhalten + Externer Signierer hat Daten zurückgegeben, die für die Anfrage ungewöhnlich sind. Es könnte ein Fehler in Amethyst oder im Signierer vorliegen. Zaps Aufrufe Boost @@ -452,6 +454,8 @@ anz der Bedingungen ist erforderlich Maximaler Zap-Betrag Konsens (0–100)% + Einzelauswahl + Mehrfachauswahl Schließdatum und -zeit Umfrage endet in %1$s Schließen nach @@ -491,6 +495,9 @@ anz der Bedingungen ist erforderlich Empfänger und die Öffentlichkeit wissen nicht, wer die Zahlung gesendet hat Keine Zap Keine Spur in Nostr, nur in Lightning + Anonym + Als neue Wegwerfidentität posten. Dein Konto wird nicht mit dieser Antwort verknüpft. + Diese Antwort wird von einer neuen anonymen Identität veröffentlicht Dateiserver Wählen Sie einen Server zum Hochladen dieser Datei LnAddress oder @Benutzer @@ -1565,6 +1572,7 @@ anz der Bedingungen ist erforderlich Shorts Sprachnachricht Sprachantwort + Web-Lesezeichen Wiki Starte mit einem großartigen Feed, indem du dieselben Personen folgst wie jemand, dem du vertraust. Folgeliste importieren @@ -1680,29 +1688,17 @@ anz der Bedingungen ist erforderlich Gesamter Zeitraum Letzte Synchronisierung: %1$s Seit letzter Synchronisierung - Diese Antwort wird von einer neuen anonymen Identität veröffentlicht - Anonym - Als neue Wegwerfidentität posten. Dein Konto wird nicht mit dieser Antwort verknüpft. - Relays - Web-Lesezeichen - Mehrfachauswahl - Einzelauswahl - Signierer hat sich unerwartet verhalten - Externer Signierer hat Daten zurückgegeben, die für die Anfrage ungewöhnlich sind. Es könnte ein Fehler in Amethyst oder im Signierer vorliegen. - Web-Lesezeichen hinzufügen - Löschen - Dieses Web-Lesezeichen löschen? - Beschreibung - Eine kurze Beschreibung - Web-Lesezeichen bearbeiten - URL öffnen - Speichern - Tags (kommagetrennt) - nostr, tech, blog - Titel - Lesezeichen-Titel - URL - https://example.com Web-Lesezeichen Noch keine Web-Lesezeichen. Tippe auf + um eines hinzuzufügen. + Web-Lesezeichen hinzufügen + Web-Lesezeichen bearbeiten + Titel + Lesezeichen-Titel + Beschreibung + Eine kurze Beschreibung + Tags (kommagetrennt) + Speichern + Löschen + Dieses Web-Lesezeichen löschen? + URL öffnen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 95c094a36..d343ba069 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1569,6 +1569,7 @@ Filmiki Wiadomość głosowa Odpowiedź głosowa + Zakładka Wiki Stwórz świetny kanał, obserwując te same osoby, które obserwuje zaufana osoba. Importuj listę obserwowanych @@ -1684,4 +1685,20 @@ Cały czas Ostatnia synchronizacja %1$s Od ostatniej synchronizacji + Zakładki + Brak zakładek. Naciśnij + aby dodać nową zakładkę. + Dodaj zakładkę + Edytuj zakładkę + Adres URL + https://domena.pl + Tytuł + Tytuł zakładki + Opis + Krótki opis + Tagi (oddzielone przecinkami) + nostr, technika, blog + Zapisz + Usuń + Usunąć tę zakładkę? + Otwórz adres URL diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 4be2136bc..7eccb0071 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -61,6 +61,8 @@ O assinador não autorizou a descriptografia necessária para realizar esta operação. Ative as descriptografias NIP-44 no seu aplicativo de assinatura e tente novamente Assinador não encontrado O aplicativo de assinatura foi desinstalado? Verifique se ele está instalado e com esta conta. Saia e entre novamente se ele foi alterado. + Assinador se comportou de forma inesperada + O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador. Zaps Contagem de visualizações Impulsionar @@ -446,6 +448,8 @@ Zap máximo Consenso (0–100)% + Escolha única + Múltipla escolha Data e hora de encerramento da enquete Enquete encerra em %1$s Fechar depois @@ -485,6 +489,9 @@ Destinatário e o público não sabem quem enviou o pagamento Sem Zap Nenhum traço no Nostr, apenas na Lightning + Anônimo + Postar como uma nova identidade descartável. Sua conta não será vinculada a esta resposta. + Esta resposta será postada a partir de uma nova identidade anônima Servidor de arquivos Escolha um servidor para onde enviar este arquivo LnAddress ou @Usuário @@ -1560,6 +1567,7 @@ Shorts Mensagem de voz Resposta de voz + Marcador Web Wiki Comece com um ótimo feed seguindo as mesmas pessoas que alguém em quem você confia. Importar lista de seguidos @@ -1675,29 +1683,17 @@ Todo o período Última sincronização: %1$s Desde a última sincronização - Esta resposta será postada a partir de uma nova identidade anônima - Anônimo - Postar como uma nova identidade descartável. Sua conta não será vinculada a esta resposta. - Relays - Marcador Web - Múltipla escolha - Escolha única - Assinador se comportou de forma inesperada - O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador. - Adicionar Marcador Web - Excluir - Excluir este marcador web? - Descrição - Uma breve descrição - Editar Marcador Web - Abrir URL - Salvar - Tags (separadas por vírgula) - nostr, tech, blog - Título - Título do marcador - URL - https://example.com Marcadores Web Nenhum marcador web ainda. Toque em + para adicionar. + Adicionar Marcador Web + Editar Marcador Web + Título + Título do marcador + Descrição + Uma breve descrição + Tags (separadas por vírgula) + Salvar + Excluir + Excluir este marcador web? + Abrir URL diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index da5af6739..758e15e19 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -72,6 +72,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Podpisnik ni odobril dešifriranja, ki je potrebno za to operacijo. V aplikaciji za podpisovanje aktivirajte NIP-44 možnost dešifriranja in poskusite znova Ne najdem podpisnika Ali je bila aplikacija za podpisovanje odstranjena? Preverite, ali je aplikacija za podpisovanje nameščena in ima dostop do tega računa. Odjavite se in ponovno prijavite, če se je aplikacija morda spremenila. + Napačno delovanje podpisnika + Zunanji podpisnik je vrnil neobičajen odgovor. Morda gre za napako v aplikaciji Amethyst ali v podpisniku. Zapi Števec vpogledov Pošlji naprej @@ -118,7 +120,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nov kanal Ime kanala Moja vrhunska skupina - Url slike + URL slike URL slike (Neobvezno) Opis Ne najdem opisa @@ -193,7 +195,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Anonimiziraj Prilagodi višino tona svojega glasu: Opomba: osnovne spremembe višine tona lahko poslušalci potencialno razveljavijo. Uporabnik nima nastavljenega \"lightning\" naslova za sprejem satoshi-jev - "odgovori tukaj.. " + "odgovori tukaj… " Kopira ID zapiska v odložišče za deljenje v Nostr Kopiraj ID kanala (zapisek) v odložišče Uredi metapodatke kanala @@ -301,6 +303,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nostr naslov t. i. Nip-05 nikoli zdaj + sekunde h m d @@ -387,7 +390,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ročno razdeli zape Zaznamki Privzeti zaznamki - Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev. + Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev Osnutki Privatni zaznamki Javni zaznamki @@ -441,7 +444,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Napredno: ročni vnos podatkov o povezavi Zneski za hitre zape Prikaže se ob pritisku na gumb za zappe. Tapnite znesek, da ga odstranite. Če pustite prazno, se bo ob vsakem zappu odprlo okno za vnos poljubnega zneska. - Zap zasebnost + Zasebnost zapov Določa, kako je prikazana vaša identiteta, ko pošljete zap. Poveži denarnico Pogled v vsebino releja @@ -458,6 +461,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zap maksimum Soglasje (0–100)% + Ena izbira + Več izbire Datum in čas konca glasovanja Anketa se zaključi %1$s Zapri po @@ -497,6 +502,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prejemnik in javnost ne vejo kdo je poslal plačilo Ni-Zap Brez sledi v Nostr, samo v Lightning + Anonimno + Objavite z novo začasno identiteto. Vaš račun ne bo povezan s tem odgovorom. + Ta odgovor bo objavljen z novo anonimno identiteto Datotečni strežnik Izberi strežnik za nalaganje te datoteke LnNaslov ali @Uporabnik @@ -753,7 +761,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nadzor dostopa Minimalen PoW Auth - Potrebna avtentikacija + Potrebna je avtentikacija Plačilo Potrebno je plačilo Največja dolžina sporočila @@ -826,7 +834,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Slog galerije profila Izberi slog galerije Naloži sliko - Pošiljatelji nezaželjenih vsebin + Pošiljatelji nezaželenih vsebin Utišano. Klikni za vklop zvoka Zvok je prižgan. Klikni da ga utišaš Skoči nazaj za %d sekund @@ -1260,6 +1268,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. + Uredi članek Uredi objavo Prošnja za izboljšavo objave Povzetek sprememb @@ -1339,6 +1348,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ključniki Skupnosti Seznami + Releji Odjava ob zaklepu naprave Zasebno sporočilo Javno sporočilo @@ -1575,6 +1585,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kratki videoposnetki Zvočno sporočilo Zvočni odgovori + Spletni zaznamek Wiki Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate. Uvozi seznam sledenih @@ -1690,4 +1701,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vse do zdaj Zadnja sinhronizacija: %1$s Od zadnje sinhronizacije + Spletni zaznamki + Seznam spletnih zaznamkov je prazen. Dodajte ga s pritiskom na +. + Dodaj spletni zaznamek + Uredi spletni zaznamek + URL + https://primer.si + Naslov + Naslov zaznamka + Opis + Kratek opis + Oznake (Ločite z vejicami) + nostr, tehnika , blog + Shrani + Izbriši + Izbriši ta spletni zaznamek + Odpri URL diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index a958f969f..640a888bc 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -61,6 +61,8 @@ Signatären har inte godkänt den dekryptering som krävs. Aktivera NIP-44-dekryptering i din signeringsapp och försök igen Signatör saknas Har signeringsappen avinstallerats? Kontrollera om den är installerad och innehåller det här kontot. Logga ut och in igen om den har ändrats. + Signatären betedde sig oväntat + Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären. Zaps Antal visningar Boosta @@ -446,6 +448,8 @@ Maximal Zap Konsensus (0–100)% + Enkelt val + Flerval Stängningsdatum och tid Omröstningen stänger om %1$s Avsluta efter @@ -485,6 +489,9 @@ Mottagaren och allmänheten vet inte vem som skickade betalningen Ingen Zap Inga spår i Nostr, bara i Lightning + Anonym + Publicera som en ny engångsidentitet. Ditt konto kommer inte att kopplas till detta svar. + Detta svar kommer att publiceras från en ny anonym identitet Fil Server Välj en server att ladda upp denna fil till LnAdress eller @Användare @@ -1323,6 +1330,7 @@ Hashtaggar Gemenskaper Listor + Reläer Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1559,6 +1567,7 @@ Shorts Röstmeddelande Röstsvar + Webbbokmärke Wiki Kom igång med ett bra flöde genom att följa samma personer som någon du litar på. Importera följarlista @@ -1674,29 +1683,17 @@ All tid Senaste synkronisering: %1$s Sedan senaste synkronisering - Detta svar kommer att publiceras från en ny anonym identitet - Anonym - Publicera som en ny engångsidentitet. Ditt konto kommer inte att kopplas till detta svar. - Reläer - Webbbokmärke - Flerval - Enkelt val - Signatären betedde sig oväntat - Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären. - Lägg till webbbokmärke - Radera - Radera detta webbbokmärke? - Beskrivning - En kort beskrivning - Redigera webbbokmärke - Öppna URL - Spara - Taggar (kommaseparerade) - nostr, tech, blog - Titel - Bokmärkets titel - URL - https://example.com Webbbokmärken Inga webbbokmärken ännu. Tryck på + för att lägga till. + Lägg till webbbokmärke + Redigera webbbokmärke + Titel + Bokmärkets titel + Beskrivning + En kort beskrivning + Taggar (kommaseparerade) + Spara + Radera + Radera detta webbbokmärke? + Öppna URL diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 38d929d10..a334d9e2a 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -28,8 +28,8 @@ 中继器图标 未知作者 复制文本 - 复制作者@npub - 复制笔记ID + 复制作者公钥 ID + 复制笔记 ID 广播 获取公开时间戳 OpenTimestamps:待确认 @@ -61,6 +61,8 @@ 签名器没有授权解密操作,请在签名器中授予 NIP-44 解密权限并重试。 未找到签名器 签名器被卸载?请检查是否已经安装了签名器以及其中是否存在该账户。变更签名器需要注销后重新登录。 + 签名器行为异常 + 外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。 打闪 浏览次数 提升 @@ -70,7 +72,7 @@ 原版 引用 复刻 - 提议编辑 + 提出修改建议 新的聪金额 添加 "回复 " @@ -114,7 +116,7 @@ "关于我们.. " 你在想什么? 写一条消息… - 发布 + 贴文 保存 创建 重命名 @@ -183,7 +185,7 @@ 更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。 用户尚未设置闪电地址以接收聪 "🔏在此回复… " - 复制笔记ID到剪贴板,供分享 + 复制笔记ID到剪贴板以便于在 Nostr 中分享 复制频道ID(笔记)到剪贴板 修改频道元数据 加入 @@ -196,7 +198,7 @@ Mod 队列 笔记 回复 - 你的 + 互动 相册 "关注" "举报" @@ -326,7 +328,7 @@ 你收到了新的徽章奖励 徽章奖励授予 文本已复制到剪贴板 - 复制作者的 @npub 到剪贴板 + 已复制作者公钥 ID 到剪贴板 已复制笔记ID (@note1) 到剪贴板 选择文本 "<无法解密私密消息>\n\n;你被 %1$s 和 %2$s 之间的私人/加密会话引用。" @@ -597,7 +599,7 @@ Orbot Socks 端口 启动 Tor - 使用内置的版本或者 Orbot + 使用内置 Tor 或者 Orbot Tor 和隐私预设 快速修改下方的设置 Onion 链接或中继地址 @@ -1131,7 +1133,7 @@ 调整顺序 回复 回复此笔记 - Boost + 提升 转发或引用此笔记 点赞 使用表情符号回应笔记 @@ -1334,6 +1336,7 @@ 话题标签 社区 列表 + 中继 当设备锁定时注销 私信 公开消息 @@ -1570,6 +1573,7 @@ 短篇 语音消息 语音回复 + 网络书签 维基 关注你信任的人所关注的人来开启优质的源。 导入关注列表 @@ -1639,7 +1643,7 @@ 私信 个人资料 中继设置 - 上次看见在 %1$s 秒前 + %1$s 前活跃 <%1$s 连接中 下载中 @@ -1685,4 +1689,20 @@ 全部时间 上次同步: %1$s 自上次同步后 + 网络书签 + 暂无网络书签。点击 + 添加一个。 + 添加网络书签 + 编辑网络书签 + URL + https://example.com + 标题 + 书签标题 + 描述 + 一段简短描述 + 标签(以逗号分隔) + nostr, tech, blog + 保存 + 删除 + 删除该网络书签? + 打开 URL