From 55c45480b431c9277874901d50746f7b8e38a769 Mon Sep 17 00:00:00 2001 From: M Date: Sun, 8 Mar 2026 16:01:25 +1100 Subject: [PATCH] feat: add custom ElectrumX server settings for Namecoin resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'Namecoin Resolution' section that lets users configure custom ElectrumX servers for .bit / d/ / id/ name resolution. When custom servers are configured, they are used EXCLUSIVELY — the hardcoded public defaults are completely ignored. This gives privacy-conscious users full control over which servers observe their name lookups. Server format: host:port (TLS default) or host:port:tcp (plaintext). Supports .onion addresses for Tor-routed lookups. New files: NamecoinSettings.kt Immutable settings data class with server string parsing/formatting. NamecoinSharedPreferences.kt DataStore-backed persistence following the TorSharedPreferences pattern. Provides a synchronous snapshot for the serverListProvider lambda and observable StateFlow for the UI. NamecoinSettingsSection.kt Compose UI: enable/disable toggle, active server display with DEFAULT/CUSTOM badge, add/remove servers with validation, reset. NamecoinSettingsTest.kt Unit tests for parsing, formatting, round-trips, and edge cases. Integration: AppModules.kt — added namecoinPrefs (NamecoinSharedPreferences) and wired customServersOrNull into the existing serverListProvider so user-configured servers take priority over the Tor/default logic. Refactored from an earlier patch to avoid duplicating code already in main. The quartz-layer ElectrumXClient, NamecoinNameResolver, and supporting types are reused unchanged. --- .../com/vitorpamplona/amethyst/AppModules.kt | 18 +- .../preferences/NamecoinSharedPreferences.kt | 143 ++++++ .../service/namecoin/NamecoinSettings.kt | 94 ++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../loggedIn/privacy/PrivacyOptionsScreen.kt | 35 +- .../settings/NamecoinSettingsSection.kt | 416 ++++++++++++++++++ .../service/namecoin/NamecoinSettingsTest.kt | 181 ++++++++ 7 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7856ab8b2..f9fe491d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -105,6 +106,11 @@ class AppModules( TorSharedPreferences(appContext, applicationIOScope) } + // Namecoin ElectrumX server preferences (global, like Tor settings) + val namecoinPrefs by lazy { + NamecoinSharedPreferences(appContext, applicationIOScope) + } + // App services that should be run as soon as there are subscribers to their flows val locationManager = LocationState(appContext, applicationIOScope) val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -156,11 +162,13 @@ class AppModules( NamecoinNameResolver( electrumxClient = namecoinElectrumxClient, serverListProvider = { - if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { - TOR_ELECTRUMX_SERVERS - } else { - DEFAULT_ELECTRUMX_SERVERS - } + // User-configured custom servers take priority + namecoinPrefs.customServersOrNull + ?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + TOR_ELECTRUMX_SERVERS + } else { + DEFAULT_ELECTRUMX_SERVERS + } }, ) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) 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 new file mode 100644 index 000000000..600cecaa9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.coroutines.cancellation.CancellationException + +/** + * Persistent storage for [NamecoinSettings], following the same pattern as + * [TorSharedPreferences]. + * + * Uses the app-wide [sharedPreferencesDataStore] so Namecoin resolution + * settings (like Tor settings) are global — not per-account. + * + * The current settings are available synchronously via [settings] (a + * [StateFlow]) and can be read in non-suspend contexts (e.g. in a + * `serverListProvider` lambda). + */ +@Stable +class NamecoinSharedPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + private val json = Json { ignoreUnknownKeys = true } + + companion object { + val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") + val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + } + + /** + * Current settings, loaded synchronously at init to avoid races. + */ + private val _settings = + MutableStateFlow( + runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT }, + ) + val settings: StateFlow = _settings + + /** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */ + val current: NamecoinSettings get() = _settings.value + + /** + * Parsed [ElectrumxServer] list from current custom settings, or `null` + * if the user hasn't configured any (meaning "use defaults"). + */ + val customServersOrNull: List? + get() = current.toElectrumxServers() + + // ── Mutators ─────────────────────────────────────────────────────── + + suspend fun setEnabled(enabled: Boolean) { + val updated = current.copy(enabled = enabled) + persist(updated) + } + + suspend fun addServer(server: String) { + if (server.isBlank() || server in current.customServers) return + val updated = current.copy(customServers = current.customServers + server) + persist(updated) + } + + suspend fun removeServer(server: String) { + val updated = current.copy(customServers = current.customServers - server) + persist(updated) + } + + suspend fun reset() { + persist(NamecoinSettings.DEFAULT) + } + + // ── Internal ─────────────────────────────────────────────────────── + + private suspend fun persist(settings: NamecoinSettings) { + _settings.value = settings + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_ENABLED] = settings.enabled + prefs[KEY_CUSTOM_SERVERS] = + json.encodeToString( + settings.customServers.filter { it.isNotBlank() }, + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error writing DataStore: ${e.message}") + } + } + + private suspend fun loadFromDisk(): NamecoinSettings? = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val enabled = prefs[KEY_ENABLED] ?: true + val serversJson = prefs[KEY_CUSTOM_SERVERS] + val servers = + if (serversJson != null) { + try { + json.decodeFromString>(serversJson) + } catch (_: Exception) { + emptyList() + } + } else { + emptyList() + } + NamecoinSettings(enabled = enabled, customServers = servers) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error reading DataStore: ${e.message}") + null + } +} 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 new file mode 100644 index 000000000..c453622ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.namecoin + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import kotlinx.serialization.Serializable + +/** + * Immutable data class representing the current Namecoin resolution config. + * + * When custom servers are configured, they are used EXCLUSIVELY and the + * hardcoded defaults are ignored. This gives privacy-conscious users full + * control over which ElectrumX servers observe their name lookups. + */ +@Serializable +data class NamecoinSettings( + /** Whether Namecoin resolution is enabled at all. */ + val enabled: Boolean = true, + /** + * Custom ElectrumX servers. When non-empty, these replace the defaults. + * + * Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext). + */ + val customServers: List = emptyList(), +) { + /** True when the user has configured at least one custom server. */ + val hasCustomServers: Boolean get() = customServers.isNotEmpty() + + /** + * Convert to [ElectrumxServer] instances used by the resolver. + * Returns `null` when no valid custom servers are configured (use defaults). + */ + fun toElectrumxServers(): List? { + if (customServers.isEmpty()) return null + return customServers + .mapNotNull { parseServerString(it) } + .ifEmpty { null } + } + + companion object { + val DEFAULT = NamecoinSettings() + + /** + * Parse `host:port` or `host:port:tcp` into an [ElectrumxServer]. + * + * TLS is the default protocol. Append `:tcp` for plaintext + * (useful for `.onion` addresses and local servers). + * + * `.onion` addresses automatically get `trustAllCerts = true` + * since certificate verification is meaningless over Tor. + */ + fun parseServerString(s: String): ElectrumxServer? { + val parts = s.trim().split(":") + if (parts.size < 2) return null + val host = parts[0].trim() + val port = parts[1].trim().toIntOrNull() ?: return null + if (host.isEmpty() || port <= 0 || port > 65535) return null + val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" + val isOnion = host.endsWith(".onion") + return ElectrumxServer( + host = host, + port = port, + useSsl = useSsl, + trustAllCerts = isOnion || !useSsl, + ) + } + + /** + * Format an [ElectrumxServer] back to the `host:port[:tcp]` string form. + */ + fun formatServerString(server: ElectrumxServer): String { + val base = "${server.host}:${server.port}" + return if (server.useSsl) base else "$base:tcp" + } + } +} 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 f032e1825..b06386b20 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 @@ -171,7 +171,7 @@ fun AppNavigation( composableFromEnd { AllSettingsScreen(accountViewModel, nav) } composableFromEnd { AccountBackupScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } - composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } + composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, Amethyst.instance.namecoinPrefs, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index 5a21fcb12..5d65ae4f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -21,29 +21,38 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsSection import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen( torSettingsFlow: TorSettingsFlow, + namecoinPrefs: NamecoinSharedPreferences, nav: INav, ) { val dialogViewModel = viewModel() @@ -58,16 +67,20 @@ fun PrivacyOptionsScreen( torSettings } - PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav) + PrivacyOptionsScreenContents(dialogViewModel, namecoinPrefs, onPost = torSettingsFlow::update, nav) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreenContents( dialogViewModel: TorDialogViewModel, + namecoinPrefs: NamecoinSharedPreferences, onPost: (TorSettings) -> Unit, nav: INav, ) { + val namecoinSettings by namecoinPrefs.settings.collectAsState() + val scope = rememberCoroutineScope() + Scaffold( topBar = { SavingTopBar( @@ -91,6 +104,26 @@ fun PrivacyOptionsScreenContents( ).padding(horizontal = 10.dp), ) { PrivacySettingsBody(dialogViewModel) + + Spacer(Modifier.height(16.dp)) + + NamecoinSettingsSection( + settings = namecoinSettings, + onToggleEnabled = { enabled -> + scope.launch { namecoinPrefs.setEnabled(enabled) } + }, + onAddServer = { server -> + scope.launch { namecoinPrefs.addServer(server) } + }, + onRemoveServer = { server -> + scope.launch { namecoinPrefs.removeServer(server) } + }, + onReset = { + scope.launch { namecoinPrefs.reset() } + }, + ) + + Spacer(Modifier.height(16.dp)) } } } 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 new file mode 100644 index 000000000..9838b9fcd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -0,0 +1,416 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +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.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.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.service.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS + +/** + * Complete settings section for Namecoin ElectrumX server configuration. + * + * Designed to sit in the Privacy / Settings screen alongside existing + * Tor settings. + * + * @param settings Current [NamecoinSettings] state + * @param onToggleEnabled Called when user toggles the master switch + * @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 + */ +@Composable +fun NamecoinSettingsSection( + settings: NamecoinSettings, + onToggleEnabled: (Boolean) -> Unit, + onAddServer: (String) -> Unit, + onRemoveServer: (String) -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // ── Section header ───────────────────────────────────────── + SectionHeader(enabled = settings.enabled, onToggle = onToggleEnabled) + + AnimatedVisibility( + visible = settings.enabled, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(12.dp)) + + // ── Explanation ───────────────────────────────────── + Text( + "Namecoin names (.bit, d/, id/) are resolved via ElectrumX servers. " + + "By default, public community servers are used. " + + "For maximum privacy, add your own server below — when custom " + + "servers are set, the defaults are completely ignored.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + // ── Active servers display ───────────────────────── + ActiveServersDisplay(settings = settings) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(12.dp)) + + // ── Custom servers list ──────────────────────────── + CustomServersList( + servers = settings.customServers, + onRemove = onRemoveServer, + ) + + // ── Add server input ─────────────────────────────── + AddServerInput(onAdd = onAddServer) + + Spacer(Modifier.height(8.dp)) + + // ── Reset button ─────────────────────────────────── + if (settings.hasCustomServers) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onReset) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Reset to defaults") + } + } + } + } + } + } + } +} + +// ── Sub-composables ──────────────────────────────────────────────────── + +@Composable +private fun SectionHeader( + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Lock, + contentDescription = null, + tint = Color(0xFF4A90D9), // Namecoin blue + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + Column { + Text( + "Namecoin Resolution", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Blockchain identity lookups (.bit)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = enabled, + onCheckedChange = onToggle, + ) + } +} + +@Composable +private fun ActiveServersDisplay(settings: NamecoinSettings) { + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + val isCustom = settings.hasCustomServers + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Active servers", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + if (isCustom) { + Text( + "CUSTOM", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF4A90D9), + modifier = + Modifier + .background( + Color(0xFF4A90D9).copy(alpha = 0.1f), + RoundedCornerShape(4.dp), + ).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } else { + Text( + "DEFAULT", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(6.dp)) + servers.forEach { server -> + ServerRow( + displayText = + "${server.host}:${server.port}" + + if (!server.useSsl) " (tcp)" else " (tls)", + isActive = true, + ) + } + } +} + +@Composable +private fun CustomServersList( + servers: List, + onRemove: (String) -> Unit, +) { + if (servers.isEmpty()) { + Text( + "No custom servers configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + Text( + "Custom servers (used exclusively)", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 4.dp), + ) + servers.forEach { server -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = server, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { onRemove(server) }, + modifier = Modifier.size(28.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove server", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun AddServerInput(onAdd: (String) -> Unit) { + var input by rememberSaveable { mutableStateOf("") } + var validationError by remember { mutableStateOf(null) } + val kb = LocalSoftwareKeyboardController.current + + fun tryAdd() { + val trimmed = input.trim() + if (trimmed.isBlank()) { + validationError = "Enter a server address" + return + } + val parsed = NamecoinSettings.parseServerString(trimmed) + if (parsed == null) { + validationError = "Invalid format. Use host:port or host:port:tcp" + return + } + validationError = null + onAdd(trimmed) + input = "" + kb?.hide() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + OutlinedTextField( + value = input, + onValueChange = { + input = it + validationError = null + }, + label = { Text("Add ElectrumX server") }, + placeholder = { Text("host:port or host:port:tcp") }, + singleLine = true, + isError = validationError != null, + supportingText = + validationError?.let { err -> + { Text(err, color = MaterialTheme.colorScheme.error) } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { tryAdd() }), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { tryAdd() }, + modifier = + Modifier + .padding(top = 8.dp) + .size(40.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + RoundedCornerShape(8.dp), + ), + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add server", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun ServerRow( + displayText: String, + isActive: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "•", + fontSize = 10.sp, + color = + if (isActive) { + Color(0xFF2E8B57) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(end = 6.dp), + ) + Text( + text = displayText, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} 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 new file mode 100644 index 000000000..4eb495af6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.namecoin + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NamecoinSettingsTest { + // ── Server string parsing ────────────────────────────────────────── + + @Test + fun `parses host colon port as TLS`() { + val s = NamecoinSettings.parseServerString("example.com:50006") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + assertTrue(s.useSsl) + } + + @Test + fun `parses host colon port colon tcp as plaintext`() { + val s = NamecoinSettings.parseServerString("example.com:50001:tcp") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + } + + @Test + fun `parses onion address`() { + val s = NamecoinSettings.parseServerString("abc123def.onion:50001:tcp") + assertNotNull(s) + assertEquals("abc123def.onion", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + assertTrue(s.trustAllCerts) + } + + @Test + fun `trims whitespace`() { + val s = NamecoinSettings.parseServerString(" example.com : 50006 ") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + } + + @Test + fun `rejects empty host`() { + assertNull(NamecoinSettings.parseServerString(":50006")) + } + + @Test + fun `rejects invalid port`() { + assertNull(NamecoinSettings.parseServerString("example.com:abc")) + assertNull(NamecoinSettings.parseServerString("example.com:0")) + assertNull(NamecoinSettings.parseServerString("example.com:99999")) + } + + @Test + fun `rejects no port`() { + assertNull(NamecoinSettings.parseServerString("example.com")) + } + + // ── Format round-trip ────────────────────────────────────────────── + + @Test + fun `formats TLS server without suffix`() { + val server = ElectrumxServer("example.com", 50006, true) + assertEquals("example.com:50006", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `formats TCP server with tcp suffix`() { + val server = ElectrumxServer("example.com", 50001, false) + assertEquals("example.com:50001:tcp", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `round-trips server string through parse and format`() { + val original = "myserver.onion:50001:tcp" + val parsed = NamecoinSettings.parseServerString(original)!! + val formatted = NamecoinSettings.formatServerString(parsed) + assertEquals(original, formatted) + } + + // ── toElectrumxServers ───────────────────────────────────────────── + + @Test + fun `returns null when no custom servers`() { + val settings = NamecoinSettings(customServers = emptyList()) + assertNull(settings.toElectrumxServers()) + } + + @Test + fun `returns parsed list for valid custom servers`() { + val settings = + NamecoinSettings( + customServers = + listOf( + "server1.com:50006", + "server2.onion:50001:tcp", + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(2, servers!!.size) + assertEquals("server1.com", servers[0].host) + assertTrue(servers[0].useSsl) + assertEquals("server2.onion", servers[1].host) + assertFalse(servers[1].useSsl) + assertTrue(servers[1].trustAllCerts) + } + + @Test + fun `skips invalid entries in custom server list`() { + val settings = + NamecoinSettings( + customServers = + listOf( + "valid.com:50006", + "invalid", // no port + "also-invalid:abc", // non-numeric port + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(1, servers!!.size) + assertEquals("valid.com", servers[0].host) + } + + @Test + fun `returns null when all custom servers are invalid`() { + val settings = NamecoinSettings(customServers = listOf("bad", "also-bad")) + assertNull(settings.toElectrumxServers()) + } + + // ── hasCustomServers flag ────────────────────────────────────────── + + @Test + fun `hasCustomServers is false when empty`() { + assertFalse(NamecoinSettings().hasCustomServers) + } + + @Test + fun `hasCustomServers is true when populated`() { + assertTrue(NamecoinSettings(customServers = listOf("x:1")).hasCustomServers) + } + + // ── Default settings ─────────────────────────────────────────────── + + @Test + fun `default settings are enabled with no custom servers`() { + val d = NamecoinSettings.DEFAULT + assertTrue(d.enabled) + assertTrue(d.customServers.isEmpty()) + assertFalse(d.hasCustomServers) + } +}