Port Namecoin NIP-05 resolution to Desktop app
Add censorship-resistant NIP-05 verification using the Namecoin blockchain to the Desktop (JVM) app, porting functionality from PRs #1734, #1771, New files: - DesktopNamecoinNameService: app-level service wrapping the Quartz NamecoinNameResolver with caching, custom server support, and live state flows. Uses plain JVM sockets (no Tor support on Desktop yet). - DesktopNamecoinPreferences: Java Preferences API-backed persistence for Namecoin settings (enabled toggle + custom ElectrumX servers). - NamecoinSettings: Desktop copy of the settings data class (no Android dependencies). - LocalNamecoin: CompositionLocals for threading Namecoin service/prefs through the compose tree. - NamecoinSettingsSection: Compose Desktop UI for configuring ElectrumX servers — toggle, active server display with DEFAULT/CUSTOM badge, add/remove custom servers, reset to defaults. Uses onPreviewKeyEvent for Enter-to-submit instead of Android KeyboardActions. - ImportFollowListDialog: Dialog for importing follow lists via npub, hex, NIP-05, or Namecoin identifiers. Resolves identifiers to pubkeys with Namecoin blockchain support. Modified: - Main.kt: Instantiate DesktopNamecoinPreferences and DesktopNamecoinNameService in App composable, provide via CompositionLocals, wire into RelaySettingsScreen. - SearchScreen.kt: Detect Namecoin identifiers (.bit, d/, id/) in the search bar, resolve via DesktopNamecoinNameService with loading/error states, display resolved user above standard results. Uses LaunchedEffect with key-based cancellation for stale lookups. - DeckColumnContainer.kt: Pass namecoinPreferences to RelaySettingsScreen from CompositionLocal. Tests: - DesktopNamecoinPreferencesTest: round-trip persistence, add/remove servers, enable/disable, reset, duplicate handling. - NamecoinSettingsTest: server string parsing/formatting, round-trips, edge cases, toElectrumxServers conversion. The Quartz KMP library (commonMain + jvmAndroid) already contains the core Namecoin resolution code (ElectrumXClient, NamecoinNameResolver, NamecoinLookupCache) shared across Android and Desktop.
This commit is contained in:
@@ -110,10 +110,15 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.param
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -695,6 +700,10 @@ fun App(
|
||||
}
|
||||
|
||||
val localCache = remember { DesktopLocalCache() }
|
||||
val namecoinPreferences = remember { DesktopNamecoinPreferences() }
|
||||
val namecoinService = remember {
|
||||
DesktopNamecoinNameService(preferencesProvider = { namecoinPreferences.current })
|
||||
}
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||
|
||||
@@ -872,6 +881,10 @@ fun App(
|
||||
ProvideMaterialSymbols(
|
||||
weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalNamecoinPreferences provides namecoinPreferences,
|
||||
LocalNamecoinService provides namecoinService,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
@@ -1036,6 +1049,7 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1425,6 +1439,7 @@ fun RelaySettingsScreen(
|
||||
com.vitorpamplona.amethyst.commons.tor
|
||||
.TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF),
|
||||
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {},
|
||||
namecoinPreferences: DesktopNamecoinPreferences? = null,
|
||||
) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
@@ -1635,6 +1650,7 @@ fun RelaySettingsScreen(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
items(relayStatuses.values.toList(), key = { it.url.url }) { status ->
|
||||
RelayStatusCard(
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.desktop.service.namecoin
|
||||
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.net.SocketFactory
|
||||
|
||||
/**
|
||||
* Desktop application-level singleton for Namecoin name resolution.
|
||||
*
|
||||
* Same functionality as the Android `NamecoinNameService` but instantiated
|
||||
* directly (no Koin/Hilt DI). Uses plain JVM sockets (no Tor support on
|
||||
* Desktop yet).
|
||||
*/
|
||||
class DesktopNamecoinNameService(
|
||||
private val preferencesProvider: () -> NamecoinSettings = { NamecoinSettings.DEFAULT },
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val electrumxClient = ElectrumXClient(
|
||||
socketFactory = { SocketFactory.getDefault() },
|
||||
)
|
||||
|
||||
private val resolver = NamecoinNameResolver(
|
||||
electrumxClient = electrumxClient,
|
||||
serverListProvider = {
|
||||
val settings = preferencesProvider()
|
||||
settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
|
||||
},
|
||||
)
|
||||
|
||||
private val cache = NamecoinLookupCache()
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a Namecoin identifier to a Nostr pubkey.
|
||||
*
|
||||
* Returns cached results when available. This is the primary method
|
||||
* that the search bar and NIP-05 verifier should call.
|
||||
*
|
||||
* @param identifier e.g. "alice@example.bit", "id/bob", "example.bit"
|
||||
* @return [NamecoinNostrResult] or null
|
||||
*/
|
||||
suspend fun resolve(identifier: String): NamecoinNostrResult? {
|
||||
val cached = cache.get(identifier)
|
||||
if (cached != null) return cached.result
|
||||
|
||||
val result = resolver.resolve(identifier)
|
||||
cache.put(identifier, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and return just the hex pubkey, or null.
|
||||
* Convenience for follow-import integration.
|
||||
*/
|
||||
suspend fun resolvePubkey(identifier: String): String? = resolve(identifier)?.pubkey
|
||||
|
||||
/**
|
||||
* Resolve with detailed outcome for error reporting.
|
||||
*/
|
||||
suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome =
|
||||
resolver.resolveDetailed(identifier)
|
||||
|
||||
/**
|
||||
* Verify that a Namecoin name maps to the expected pubkey.
|
||||
*/
|
||||
suspend fun verifyNip05(
|
||||
nip05Address: String,
|
||||
expectedPubkeyHex: String,
|
||||
): Boolean {
|
||||
if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) return false
|
||||
val result = resolve(nip05Address) ?: return false
|
||||
return result.pubkey.equals(expectedPubkeyHex, ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a lookup and emit results via a StateFlow.
|
||||
*
|
||||
* Useful for composable UIs that observe resolution state.
|
||||
*/
|
||||
fun resolveLive(
|
||||
identifier: String,
|
||||
scope: CoroutineScope = this.scope,
|
||||
): StateFlow<NamecoinResolveState> {
|
||||
val state = MutableStateFlow<NamecoinResolveState>(NamecoinResolveState.Loading)
|
||||
scope.launch {
|
||||
try {
|
||||
val result = resolve(identifier)
|
||||
state.value = if (result != null) {
|
||||
NamecoinResolveState.Resolved(result)
|
||||
} else {
|
||||
NamecoinResolveState.NotFound
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state.value = NamecoinResolveState.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the resolution cache.
|
||||
*/
|
||||
suspend fun clearCache() = cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable state for a Namecoin resolution in progress.
|
||||
*/
|
||||
sealed class NamecoinResolveState {
|
||||
data object Loading : NamecoinResolveState()
|
||||
|
||||
data class Resolved(
|
||||
val result: NamecoinNostrResult,
|
||||
) : NamecoinResolveState()
|
||||
|
||||
data object NotFound : NamecoinResolveState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : NamecoinResolveState()
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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.desktop.service.namecoin
|
||||
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Persistent storage for [NamecoinSettings] on Desktop.
|
||||
*
|
||||
* Uses [java.util.prefs.Preferences] API, following the same pattern as
|
||||
* [com.vitorpamplona.amethyst.desktop.DesktopPreferences].
|
||||
*
|
||||
* The current settings are available synchronously via [settings] (a
|
||||
* [StateFlow]) and can be read in non-suspend contexts (e.g. in a
|
||||
* `serverListProvider` lambda).
|
||||
*/
|
||||
class DesktopNamecoinPreferences(
|
||||
private val prefs: Preferences = Preferences.userNodeForPackage(
|
||||
DesktopNamecoinPreferences::class.java,
|
||||
),
|
||||
) {
|
||||
private val mapper = jacksonObjectMapper()
|
||||
|
||||
companion object {
|
||||
private const val KEY_ENABLED = "namecoin.enabled"
|
||||
private const val KEY_CUSTOM_SERVERS = "namecoin.customServers"
|
||||
}
|
||||
|
||||
private val _settings = MutableStateFlow(loadFromDisk())
|
||||
val settings: StateFlow<NamecoinSettings> = _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<ElectrumxServer>?
|
||||
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 fun persist(settings: NamecoinSettings) {
|
||||
_settings.value = settings
|
||||
try {
|
||||
prefs.putBoolean(KEY_ENABLED, settings.enabled)
|
||||
prefs.put(
|
||||
KEY_CUSTOM_SERVERS,
|
||||
mapper.writeValueAsString(settings.customServers.filter { it.isNotBlank() }),
|
||||
)
|
||||
prefs.flush()
|
||||
} catch (e: Exception) {
|
||||
System.err.println("NamecoinPrefs: Error writing preferences: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromDisk(): NamecoinSettings {
|
||||
return try {
|
||||
val enabled = prefs.getBoolean(KEY_ENABLED, true)
|
||||
val serversJson = prefs.get(KEY_CUSTOM_SERVERS, null)
|
||||
val servers = if (serversJson != null) {
|
||||
try {
|
||||
mapper.readValue<List<String>>(serversJson)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
NamecoinSettings(enabled = enabled, customServers = servers)
|
||||
} catch (e: Exception) {
|
||||
System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}")
|
||||
NamecoinSettings.DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.desktop.service.namecoin
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
/**
|
||||
* CompositionLocal for accessing Namecoin preferences and service
|
||||
* throughout the Desktop compose tree without explicit parameter threading.
|
||||
*/
|
||||
val LocalNamecoinPreferences = compositionLocalOf<DesktopNamecoinPreferences?> { null }
|
||||
val LocalNamecoinService = compositionLocalOf<DesktopNamecoinNameService?> { null }
|
||||
+94
@@ -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.desktop.service.namecoin
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Stable
|
||||
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<String> = 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<ElectrumxServer>? {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* 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.desktop.ui
|
||||
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Import Follow List dialog for Desktop.
|
||||
*
|
||||
* Lets users enter an identifier (npub, hex, NIP-05, or Namecoin),
|
||||
* resolves it to a pubkey. The actual follow list fetching from relays
|
||||
* is a placeholder — full implementation requires relay subscription
|
||||
* infrastructure integration.
|
||||
*/
|
||||
@Composable
|
||||
fun ImportFollowListDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onImport: (List<String>) -> Unit,
|
||||
) {
|
||||
val namecoinService = LocalNamecoinService.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var resolvedPubkey by remember { mutableStateOf<String?>(null) }
|
||||
var isResolving by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var followList = remember { mutableStateListOf<FollowEntry>() }
|
||||
var isFetching by remember { mutableStateOf(false) }
|
||||
|
||||
fun resolveIdentifier() {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
error = "Enter an identifier"
|
||||
return
|
||||
}
|
||||
|
||||
error = null
|
||||
isResolving = true
|
||||
resolvedPubkey = null
|
||||
followList.clear()
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// Try bech32 first
|
||||
val bech32Result = decodePublicKeyAsHexOrNull(trimmed)
|
||||
if (bech32Result != null) {
|
||||
resolvedPubkey = bech32Result
|
||||
isResolving = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Try hex pubkey
|
||||
if (trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) {
|
||||
resolvedPubkey = trimmed.lowercase()
|
||||
isResolving = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Try Namecoin
|
||||
if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) {
|
||||
val result = namecoinService.resolvePubkey(trimmed)
|
||||
if (result != null) {
|
||||
resolvedPubkey = result
|
||||
isResolving = false
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
error = "Could not resolve identifier"
|
||||
isResolving = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Resolution failed"
|
||||
isResolving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
"Import Follow List",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Enter an npub, hex pubkey, NIP-05, or Namecoin identifier (.bit, d/, id/) " +
|
||||
"to import their follow list.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Input field
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = {
|
||||
input = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("Identifier") },
|
||||
placeholder = { Text("npub1..., alice@example.bit, d/example") },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
supportingText = error?.let { err ->
|
||||
{ Text(err, color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
modifier = Modifier.weight(1f).onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
|
||||
resolveIdentifier()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
Button(
|
||||
onClick = { resolveIdentifier() },
|
||||
enabled = input.isNotBlank() && !isResolving,
|
||||
) {
|
||||
if (isResolving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
} else {
|
||||
Text("Resolve")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved pubkey display
|
||||
if (resolvedPubkey != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
"Resolved: ${resolvedPubkey!!.take(16)}...${resolvedPubkey!!.takeLast(8)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Follow list fetching requires relay subscriptions. " +
|
||||
"The resolved pubkey can be used to follow this user directly.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Follow list preview (placeholder for when relay fetching is implemented)
|
||||
if (followList.isNotEmpty()) {
|
||||
Text(
|
||||
"Follow List (${followList.size} users)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LazyColumn(
|
||||
modifier = Modifier.height(300.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(followList) { entry ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(4.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = entry.selected,
|
||||
onCheckedChange = { entry.selected = it },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
entry.pubkey.take(16) + "...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
if (resolvedPubkey != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
resolvedPubkey?.let { pk ->
|
||||
onImport(listOf(pk))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Follow User")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FollowEntry(
|
||||
val pubkey: String,
|
||||
var selected: Boolean = true,
|
||||
)
|
||||
@@ -102,8 +102,15 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.NamecoinResolveState
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -156,6 +163,35 @@ fun SearchScreen(
|
||||
// Bech32 parsing (immediate, no debounce)
|
||||
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
|
||||
|
||||
// Namecoin resolution
|
||||
val namecoinService = LocalNamecoinService.current
|
||||
val namecoinPrefs = LocalNamecoinPreferences.current
|
||||
val namecoinEnabled = namecoinPrefs?.settings?.collectAsState()?.value?.enabled ?: false
|
||||
val isNamecoinQuery = remember(displayText) {
|
||||
displayText.isNotBlank() && NamecoinNameResolver.isNamecoinIdentifier(displayText.trim())
|
||||
}
|
||||
|
||||
var namecoinState by remember { mutableStateOf<NamecoinResolveState?>(null) }
|
||||
|
||||
// Resolve Namecoin identifiers with cancellation of stale lookups
|
||||
LaunchedEffect(displayText, namecoinEnabled) {
|
||||
if (!namecoinEnabled || !isNamecoinQuery || namecoinService == null) {
|
||||
namecoinState = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
namecoinState = NamecoinResolveState.Loading
|
||||
try {
|
||||
val result = namecoinService.resolve(displayText.trim())
|
||||
namecoinState = if (result != null) {
|
||||
NamecoinResolveState.Resolved(result)
|
||||
} else {
|
||||
NamecoinResolveState.NotFound
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
namecoinState = NamecoinResolveState.Error(e.message ?: "Resolution failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Skip people search when query specifies kinds that don't include profile (kind 0)
|
||||
val shouldSearchPeople =
|
||||
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
|
||||
|
||||
+2
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopScreen
|
||||
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
||||
@@ -336,6 +337,7 @@ internal fun RootContent(
|
||||
torStatus = torState.status,
|
||||
torSettings = torState.settings,
|
||||
onTorSettingsChanged = torState.onSettingsChanged,
|
||||
namecoinPreferences = LocalNamecoinPreferences.current,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
* 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.desktop.ui.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.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.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.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.NamecoinSettings
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
||||
|
||||
/**
|
||||
* Complete settings section for Namecoin ElectrumX server configuration.
|
||||
*
|
||||
* Desktop port of the Android `NamecoinSettingsSection.kt` composable.
|
||||
* Uses `onPreviewKeyEvent` for Enter-key handling instead of Android's
|
||||
* `KeyboardActions`/`LocalSoftwareKeyboardController`.
|
||||
*
|
||||
* @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,
|
||||
) {
|
||||
Column(modifier = modifier.padding(16.dp)) {
|
||||
// ── Section header ─────────────────────────────────────────
|
||||
NamecoinSectionHeader(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 ─────────────────────────
|
||||
NamecoinActiveServersDisplay(settings = settings)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// ── Custom servers list ────────────────────────────
|
||||
NamecoinCustomServersList(
|
||||
servers = settings.customServers,
|
||||
onRemove = onRemoveServer,
|
||||
)
|
||||
|
||||
// ── Add server input ───────────────────────────────
|
||||
NamecoinAddServerInput(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 NamecoinSectionHeader(
|
||||
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 NamecoinActiveServersDisplay(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 ->
|
||||
NamecoinServerRow(
|
||||
displayText = "${server.host}:${server.port}" +
|
||||
if (!server.useSsl) " (tcp)" else " (tls)",
|
||||
isActive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NamecoinCustomServersList(
|
||||
servers: List<String>,
|
||||
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 NamecoinAddServerInput(onAdd: (String) -> Unit) {
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var validationError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
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 = ""
|
||||
}
|
||||
|
||||
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)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
|
||||
tryAdd()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
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 NamecoinServerRow(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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.desktop.service.namecoin
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
class DesktopNamecoinPreferencesTest {
|
||||
private lateinit var testPrefs: Preferences
|
||||
private lateinit var namecoinPrefs: DesktopNamecoinPreferences
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Use a unique test node to avoid polluting real preferences
|
||||
testPrefs = Preferences.userRoot().node("amethyst-test-namecoin-${System.nanoTime()}")
|
||||
namecoinPrefs = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanup() {
|
||||
try {
|
||||
testPrefs.removeNode()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default settings are enabled with no custom servers`() {
|
||||
val settings = namecoinPrefs.current
|
||||
assertTrue(settings.enabled)
|
||||
assertTrue(settings.customServers.isEmpty())
|
||||
assertFalse(settings.hasCustomServers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setEnabled persists and updates flow`() = runBlocking {
|
||||
namecoinPrefs.setEnabled(false)
|
||||
assertFalse(namecoinPrefs.current.enabled)
|
||||
assertFalse(namecoinPrefs.settings.value.enabled)
|
||||
|
||||
// Verify persistence by creating a new instance with the same prefs node
|
||||
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||
assertFalse(reloaded.current.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addServer persists and updates flow`() = runBlocking {
|
||||
namecoinPrefs.addServer("example.com:50006")
|
||||
assertEquals(listOf("example.com:50006"), namecoinPrefs.current.customServers)
|
||||
assertTrue(namecoinPrefs.current.hasCustomServers)
|
||||
|
||||
// Verify persistence
|
||||
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||
assertEquals(listOf("example.com:50006"), reloaded.current.customServers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addServer ignores blank strings`() = runBlocking {
|
||||
namecoinPrefs.addServer("")
|
||||
namecoinPrefs.addServer(" ")
|
||||
assertTrue(namecoinPrefs.current.customServers.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addServer ignores duplicates`() = runBlocking {
|
||||
namecoinPrefs.addServer("example.com:50006")
|
||||
namecoinPrefs.addServer("example.com:50006")
|
||||
assertEquals(1, namecoinPrefs.current.customServers.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeServer persists and updates flow`() = runBlocking {
|
||||
namecoinPrefs.addServer("server1.com:50006")
|
||||
namecoinPrefs.addServer("server2.com:50001:tcp")
|
||||
assertEquals(2, namecoinPrefs.current.customServers.size)
|
||||
|
||||
namecoinPrefs.removeServer("server1.com:50006")
|
||||
assertEquals(listOf("server2.com:50001:tcp"), namecoinPrefs.current.customServers)
|
||||
|
||||
// Verify persistence
|
||||
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||
assertEquals(listOf("server2.com:50001:tcp"), reloaded.current.customServers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset restores defaults`() = runBlocking {
|
||||
namecoinPrefs.setEnabled(false)
|
||||
namecoinPrefs.addServer("example.com:50006")
|
||||
assertFalse(namecoinPrefs.current.enabled)
|
||||
assertTrue(namecoinPrefs.current.hasCustomServers)
|
||||
|
||||
namecoinPrefs.reset()
|
||||
assertTrue(namecoinPrefs.current.enabled)
|
||||
assertFalse(namecoinPrefs.current.hasCustomServers)
|
||||
|
||||
// Verify persistence
|
||||
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||
assertTrue(reloaded.current.enabled)
|
||||
assertFalse(reloaded.current.hasCustomServers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `customServersOrNull returns null when empty`() {
|
||||
assertEquals(null, namecoinPrefs.customServersOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `customServersOrNull returns parsed servers when configured`() = runBlocking {
|
||||
namecoinPrefs.addServer("example.com:50006")
|
||||
val servers = namecoinPrefs.customServersOrNull
|
||||
assertEquals(1, servers?.size)
|
||||
assertEquals("example.com", servers?.first()?.host)
|
||||
assertEquals(50006, servers?.first()?.port)
|
||||
assertTrue(servers?.first()?.useSsl == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trip multiple operations`() = runBlocking {
|
||||
namecoinPrefs.setEnabled(true)
|
||||
namecoinPrefs.addServer("server1.com:50006")
|
||||
namecoinPrefs.addServer("onion.onion:50001:tcp")
|
||||
namecoinPrefs.setEnabled(false)
|
||||
namecoinPrefs.removeServer("server1.com:50006")
|
||||
|
||||
val settings = namecoinPrefs.current
|
||||
assertFalse(settings.enabled)
|
||||
assertEquals(listOf("onion.onion:50001:tcp"), settings.customServers)
|
||||
|
||||
// Verify full persistence round-trip
|
||||
val reloaded = DesktopNamecoinPreferences(prefs = testPrefs)
|
||||
assertEquals(settings, reloaded.current)
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.desktop.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user