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
@@ -150,6 +150,141 @@ class ElectrumXClient(
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 ──────────────────────────────────────────────────────
private fun connectAndQuery(