Merge pull request #1786 from mstrofnone/feat/custom-electrumx-settings
feat: Custom ElectrumX server settings for Namecoin resolution
This commit is contained in:
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
|||||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
|
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
|
||||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
|
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
|
||||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
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.TorSharedPreferences
|
||||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||||
@@ -105,6 +106,11 @@ class AppModules(
|
|||||||
TorSharedPreferences(appContext, applicationIOScope)
|
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
|
// App services that should be run as soon as there are subscribers to their flows
|
||||||
val locationManager = LocationState(appContext, applicationIOScope)
|
val locationManager = LocationState(appContext, applicationIOScope)
|
||||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||||
@@ -156,11 +162,13 @@ class AppModules(
|
|||||||
NamecoinNameResolver(
|
NamecoinNameResolver(
|
||||||
electrumxClient = namecoinElectrumxClient,
|
electrumxClient = namecoinElectrumxClient,
|
||||||
serverListProvider = {
|
serverListProvider = {
|
||||||
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
// User-configured custom servers take priority
|
||||||
TOR_ELECTRUMX_SERVERS
|
namecoinPrefs.customServersOrNull
|
||||||
} else {
|
?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
||||||
DEFAULT_ELECTRUMX_SERVERS
|
TOR_ELECTRUMX_SERVERS
|
||||||
}
|
} else {
|
||||||
|
DEFAULT_ELECTRUMX_SERVERS
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
|
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
|
||||||
|
|||||||
+143
@@ -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<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 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<List<String>>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.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<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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,7 +171,7 @@ fun AppNavigation(
|
|||||||
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
|
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, Amethyst.instance.namecoinPrefs, nav) }
|
||||||
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||||
|
|||||||
+34
-1
@@ -21,29 +21,38 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
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.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
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.PrivacySettingsBody
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
|
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PrivacyOptionsScreen(
|
fun PrivacyOptionsScreen(
|
||||||
torSettingsFlow: TorSettingsFlow,
|
torSettingsFlow: TorSettingsFlow,
|
||||||
|
namecoinPrefs: NamecoinSharedPreferences,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val dialogViewModel = viewModel<TorDialogViewModel>()
|
val dialogViewModel = viewModel<TorDialogViewModel>()
|
||||||
@@ -58,16 +67,20 @@ fun PrivacyOptionsScreen(
|
|||||||
torSettings
|
torSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav)
|
PrivacyOptionsScreenContents(dialogViewModel, namecoinPrefs, onPost = torSettingsFlow::update, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PrivacyOptionsScreenContents(
|
fun PrivacyOptionsScreenContents(
|
||||||
dialogViewModel: TorDialogViewModel,
|
dialogViewModel: TorDialogViewModel,
|
||||||
|
namecoinPrefs: NamecoinSharedPreferences,
|
||||||
onPost: (TorSettings) -> Unit,
|
onPost: (TorSettings) -> Unit,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
|
val namecoinSettings by namecoinPrefs.settings.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
SavingTopBar(
|
SavingTopBar(
|
||||||
@@ -91,6 +104,26 @@ fun PrivacyOptionsScreenContents(
|
|||||||
).padding(horizontal = 10.dp),
|
).padding(horizontal = 10.dp),
|
||||||
) {
|
) {
|
||||||
PrivacySettingsBody(dialogViewModel)
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+416
@@ -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<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 AddServerInput(onAdd: (String) -> Unit) {
|
||||||
|
var input by rememberSaveable { mutableStateOf("") }
|
||||||
|
var validationError by remember { mutableStateOf<String?>(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user