feat: add Test Connection diagnostics to Namecoin settings

Add per-server connection testing with detailed error reporting:
- Test Connection button tests each ElectrumX server individually
- Shows streaming results:  success with response time,  failure
  with human-readable error (TLS handshake failed, connection refused,
  timeout, DNS failed, invalid response, etc.)
- Diagnostic card shows: last test timestamp + success count, device
  info (manufacturer/model/Android/API), TLS version negotiated
- ElectrumXClient.testServer() method for single-server diagnostics
  with TLS version capture from SSL session

This gives users (and bug reporters) immediate visibility into why
Namecoin resolution may be failing silently on their device.
This commit is contained in:
M
2026-03-25 11:52:05 +11:00
parent 15a2d77a0f
commit 49698de99c
7 changed files with 443 additions and 6 deletions
@@ -161,12 +161,14 @@ class AppModules(
// Custom fetcher that considers tor settings and avoids forwarding. // Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val electrumXClient =
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
)
val namecoinResolver = val namecoinResolver =
NamecoinNameResolver( NamecoinNameResolver(
electrumxClient = electrumxClient = electrumXClient,
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
),
serverListProvider = { serverListProvider = {
// User-configured custom servers take priority // User-configured custom servers take priority
namecoinPrefs.customServersOrNull namecoinPrefs.customServersOrNull
@@ -200,7 +200,7 @@ fun AppNavigation(
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, nav) }
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, Amethyst.instance.electrumXClient, nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, 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) }
@@ -38,12 +38,14 @@ import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NamecoinSettingsScreen( fun NamecoinSettingsScreen(
namecoinPrefs: NamecoinSharedPreferences, namecoinPrefs: NamecoinSharedPreferences,
electrumXClient: ElectrumXClient,
nav: INav, nav: INav,
) { ) {
val namecoinSettings by namecoinPrefs.settings.collectAsState() val namecoinSettings by namecoinPrefs.settings.collectAsState()
@@ -75,6 +77,7 @@ fun NamecoinSettingsScreen(
onReset = { onReset = {
scope.launch { namecoinPrefs.reset() } scope.launch { namecoinPrefs.reset() }
}, },
onTestServer = { server -> electrumXClient.testServer(server) },
) )
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import android.os.Build
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
@@ -41,6 +42,10 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -53,20 +58,29 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** /**
* Complete settings section for Namecoin ElectrumX server configuration. * Complete settings section for Namecoin ElectrumX server configuration.
@@ -79,6 +93,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S
* @param onAddServer Called with `host:port[:tcp]` when user adds a server * @param onAddServer Called with `host:port[:tcp]` when user adds a server
* @param onRemoveServer Called with the server string to remove * @param onRemoveServer Called with the server string to remove
* @param onReset Called when user resets to defaults * @param onReset Called when user resets to defaults
* @param onTestServer Suspend function to test a single server
*/ */
@Composable @Composable
fun NamecoinSettingsSection( fun NamecoinSettingsSection(
@@ -87,6 +102,7 @@ fun NamecoinSettingsSection(
onAddServer: (String) -> Unit, onAddServer: (String) -> Unit,
onRemoveServer: (String) -> Unit, onRemoveServer: (String) -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
onTestServer: suspend (ElectrumxServer) -> ServerTestResult,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.padding(16.dp)) { Column(modifier = modifier.padding(16.dp)) {
@@ -150,12 +166,271 @@ fun NamecoinSettingsSection(
} }
} }
} }
Spacer(Modifier.height(16.dp))
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
Spacer(Modifier.height(16.dp))
// ── Test Connection ────────────────────────────────
TestConnectionSection(
settings = settings,
onTestServer = onTestServer,
)
} }
} }
} }
} }
// ── Sub-composables ──────────────────────────────────────────────────── // ── Test Connection ────────────────────────────────────────────────────
@Composable
private fun TestConnectionSection(
settings: NamecoinSettings,
onTestServer: suspend (ElectrumxServer) -> ServerTestResult,
) {
val scope = rememberCoroutineScope()
var isTesting by remember { mutableStateOf(false) }
var testResults by remember { mutableStateOf<List<ServerTestResult>>(emptyList()) }
var lastTestTimestamp by remember { mutableStateOf<Long?>(null) }
val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
Column {
// ── Test button ────────────────────────────────────────
Button(
onClick = {
if (!isTesting) {
isTesting = true
testResults = emptyList()
scope.launch {
val results = mutableListOf<ServerTestResult>()
for (server in servers) {
val result = onTestServer(server)
results.add(result)
testResults = results.toList()
}
lastTestTimestamp = System.currentTimeMillis()
isTesting = false
}
}
},
enabled = !isTesting,
modifier = Modifier.fillMaxWidth(),
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.namecoin_testing))
} else {
Text(stringResource(R.string.namecoin_test_connection))
}
}
// ── Per-server results ─────────────────────────────────
if (testResults.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.namecoin_test_results),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(6.dp))
testResults.forEach { result ->
ServerTestResultRow(result)
}
if (isTesting && testResults.size < servers.size) {
Row(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
"Testing next server…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// ── Diagnostic card ────────────────────────────────────
if (testResults.isNotEmpty() || lastTestTimestamp != null) {
Spacer(Modifier.height(16.dp))
DiagnosticCard(
testResults = testResults,
lastTestTimestamp = lastTestTimestamp,
)
}
}
}
@Composable
private fun ServerTestResultRow(result: ServerTestResult) {
val serverLabel = "${result.server.host}:${result.server.port}"
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = if (result.success) "" else "",
fontSize = 14.sp,
modifier = Modifier.padding(end = 6.dp, top = 1.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = serverLabel,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.namecoin_response_time, result.responseTimeMs),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (result.success) {
Text(
text = stringResource(R.string.namecoin_test_success),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF2E8B57),
)
} else {
val errorText = result.error
if (errorText != null) {
Text(
text = errorText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
// ── Diagnostic Card ────────────────────────────────────────────────────
@Composable
private fun DiagnosticCard(
testResults: List<ServerTestResult>,
lastTestTimestamp: Long?,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(8.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
stringResource(R.string.namecoin_diagnostics),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
// Last test timestamp
if (lastTestTimestamp != null) {
val formatted =
remember(lastTestTimestamp) {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
.format(Date(lastTestTimestamp))
}
val successCount = testResults.count { it.success }
val totalCount = testResults.size
DiagnosticRow(
label = stringResource(R.string.namecoin_last_test),
value = "$formatted ($successCount/$totalCount OK)",
)
} else {
DiagnosticRow(
label = stringResource(R.string.namecoin_last_test),
value = stringResource(R.string.namecoin_no_test_yet),
)
}
Spacer(Modifier.height(4.dp))
// Device info
DiagnosticRow(
label = stringResource(R.string.namecoin_device_info),
value = "${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
)
Spacer(Modifier.height(4.dp))
// TLS info from test results
val tlsVersions =
testResults
.mapNotNull { it.tlsVersion }
.distinct()
val tlsDisplay =
if (tlsVersions.isNotEmpty()) {
tlsVersions.joinToString(", ")
} else {
""
}
DiagnosticRow(
label = stringResource(R.string.namecoin_tls_info),
value = tlsDisplay,
)
}
}
}
@Composable
private fun DiagnosticRow(
label: String,
value: String,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f),
)
Text(
text = value,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(0.65f),
)
}
}
// ── Original Sub-composables ───────────────────────────────────────────
@Composable @Composable
private fun SectionHeader( private fun SectionHeader(
+11
View File
@@ -1842,6 +1842,17 @@
<string name="select_all">Select All</string> <string name="select_all">Select All</string>
<string name="uptime">%1$d%% uptime</string> <string name="uptime">%1$d%% uptime</string>
<string name="namecoin_settings">Namecoin Settings</string> <string name="namecoin_settings">Namecoin Settings</string>
<string name="namecoin_test_connection">Test Connection</string>
<string name="namecoin_testing">Testing servers…</string>
<string name="namecoin_test_success">Connected</string>
<string name="namecoin_test_failed">Failed</string>
<string name="namecoin_test_results">Test Results</string>
<string name="namecoin_diagnostics">Diagnostics</string>
<string name="namecoin_last_test">Last test</string>
<string name="namecoin_device_info">Device Info</string>
<string name="namecoin_tls_info">TLS Info</string>
<string name="namecoin_no_test_yet">No test run yet</string>
<string name="namecoin_response_time">%dms</string>
<string name="event_sync_title">Relay Sync</string> <string name="event_sync_title">Relay Sync</string>
<string name="event_sync_section">Relay Sync</string> <string name="event_sync_section">Relay Sync</string>
<string name="event_sync_section_explainer">Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data.</string> <string name="event_sync_section_explainer">Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data.</string>
@@ -72,6 +72,17 @@ sealed class NamecoinLookupException(
) : NamecoinLookupException("All ElectrumX servers unreachable", lastError) ) : NamecoinLookupException("All ElectrumX servers unreachable", lastError)
} }
/**
* Result of testing connectivity to a single ElectrumX server.
*/
data class ServerTestResult(
val server: ElectrumxServer,
val success: Boolean,
val responseTimeMs: Long,
val error: String? = null,
val tlsVersion: String? = null,
)
/** Well-known public Namecoin ElectrumX servers (clearnet). */ /** Well-known public Namecoin ElectrumX servers (clearnet). */
val DEFAULT_ELECTRUMX_SERVERS = val DEFAULT_ELECTRUMX_SERVERS =
listOf( listOf(
@@ -150,6 +150,141 @@ class ElectrumXClient(
throw NamecoinLookupException.ServersUnreachable(lastError) throw NamecoinLookupException.ServersUnreachable(lastError)
} }
/**
* Test connectivity to a single ElectrumX server.
*
* Connects, negotiates protocol version, and optionally resolves a test
* name. Returns detailed results including response time, TLS version,
* and human-readable error messages.
*
* @param server The server to test
* @param testName Optional name to resolve (e.g. "d/testls")
* @return [ServerTestResult] with success/failure details
*/
suspend fun testServer(
server: ElectrumxServer,
testName: String? = "d/testls",
): ServerTestResult =
withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis()
try {
val socket = createSocket(server)
socket.soTimeout = readTimeoutMs.toInt()
val tlsVersion =
if (socket is javax.net.ssl.SSLSocket) {
socket.session.protocol
} else {
null
}
val writer = PrintWriter(socket.getOutputStream(), true)
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
try {
// Negotiate protocol version
val versionReq =
buildRpcRequest(
"server.version",
listOf("AmethystNMC/0.1", PROTOCOL_VERSION),
)
writer.println(versionReq)
val versionResponse =
reader.readLine()
?: return@withContext ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Server returned empty response",
tlsVersion = tlsVersion,
)
// If a test name is provided, try to resolve it
if (testName != null) {
val nameScript =
buildNameIndexScript(testName.toByteArray(Charsets.US_ASCII))
val scriptHash = electrumScriptHash(nameScript)
val historyReq =
buildRpcRequest(
"blockchain.scripthash.get_history",
listOf(scriptHash),
)
writer.println(historyReq)
reader.readLine() // consume response
}
val elapsed = System.currentTimeMillis() - startTime
ServerTestResult(
server = server,
success = true,
responseTimeMs = elapsed,
tlsVersion = tlsVersion,
)
} finally {
runCatching { writer.close() }
runCatching { reader.close() }
runCatching { socket.close() }
}
} catch (e: java.net.ConnectException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Connection refused",
)
} catch (e: java.net.SocketTimeoutException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Connection timed out after ${connectTimeoutMs / 1000}s",
)
} catch (e: java.net.UnknownHostException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "Server unreachable (DNS resolution failed)",
)
} catch (e: javax.net.ssl.SSLHandshakeException) {
val detail =
if (e.message?.contains("self-signed", ignoreCase = true) == true ||
e.message?.contains("anchor", ignoreCase = true) == true
) {
"TLS handshake failed (self-signed certificate rejected)"
} else {
"TLS handshake failed: ${e.message?.take(100) ?: "unknown error"}"
}
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = detail,
)
} catch (e: javax.net.ssl.SSLException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "TLS error: ${e.message?.take(100) ?: "unknown"}",
)
} catch (e: java.io.IOException) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = "I/O error: ${e.message?.take(100) ?: "unknown"}",
)
} catch (e: Exception) {
ServerTestResult(
server = server,
success = false,
responseTimeMs = System.currentTimeMillis() - startTime,
error = e.message?.take(150) ?: "Unknown error",
)
}
}
// ── internals ────────────────────────────────────────────────────── // ── internals ──────────────────────────────────────────────────────
private fun connectAndQuery( private fun connectAndQuery(