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.TorAwareOkHttpOtsResolverBuilder
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
@@ -105,6 +106,11 @@ class AppModules(
|
||||
TorSharedPreferences(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// Namecoin ElectrumX server preferences (global, like Tor settings)
|
||||
val namecoinPrefs by lazy {
|
||||
NamecoinSharedPreferences(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// App services that should be run as soon as there are subscribers to their flows
|
||||
val locationManager = LocationState(appContext, applicationIOScope)
|
||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||
@@ -156,11 +162,13 @@ class AppModules(
|
||||
NamecoinNameResolver(
|
||||
electrumxClient = namecoinElectrumxClient,
|
||||
serverListProvider = {
|
||||
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
||||
TOR_ELECTRUMX_SERVERS
|
||||
} else {
|
||||
DEFAULT_ELECTRUMX_SERVERS
|
||||
}
|
||||
// User-configured custom servers take priority
|
||||
namecoinPrefs.customServersOrNull
|
||||
?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
||||
TOR_ELECTRUMX_SERVERS
|
||||
} else {
|
||||
DEFAULT_ELECTRUMX_SERVERS
|
||||
}
|
||||
},
|
||||
)
|
||||
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
|
||||
|
||||
+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.AccountBackup> { AccountBackupScreen(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.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||
|
||||
+34
-1
@@ -21,29 +21,38 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsSection
|
||||
import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PrivacyOptionsScreen(
|
||||
torSettingsFlow: TorSettingsFlow,
|
||||
namecoinPrefs: NamecoinSharedPreferences,
|
||||
nav: INav,
|
||||
) {
|
||||
val dialogViewModel = viewModel<TorDialogViewModel>()
|
||||
@@ -58,16 +67,20 @@ fun PrivacyOptionsScreen(
|
||||
torSettings
|
||||
}
|
||||
|
||||
PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav)
|
||||
PrivacyOptionsScreenContents(dialogViewModel, namecoinPrefs, onPost = torSettingsFlow::update, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PrivacyOptionsScreenContents(
|
||||
dialogViewModel: TorDialogViewModel,
|
||||
namecoinPrefs: NamecoinSharedPreferences,
|
||||
onPost: (TorSettings) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val namecoinSettings by namecoinPrefs.settings.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SavingTopBar(
|
||||
@@ -91,6 +104,26 @@ fun PrivacyOptionsScreenContents(
|
||||
).padding(horizontal = 10.dp),
|
||||
) {
|
||||
PrivacySettingsBody(dialogViewModel)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
NamecoinSettingsSection(
|
||||
settings = namecoinSettings,
|
||||
onToggleEnabled = { enabled ->
|
||||
scope.launch { namecoinPrefs.setEnabled(enabled) }
|
||||
},
|
||||
onAddServer = { server ->
|
||||
scope.launch { namecoinPrefs.addServer(server) }
|
||||
},
|
||||
onRemoveServer = { server ->
|
||||
scope.launch { namecoinPrefs.removeServer(server) }
|
||||
},
|
||||
onReset = {
|
||||
scope.launch { namecoinPrefs.reset() }
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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