app: show what the server reports as facts, not as inputs

The settings card offered three editable boxes and said nothing about the
server itself — which addresses a test will actually use, on which ports,
what it can measure. That is the part a person checks before trusting a
result, and "which address did this come from" is precisely the question
a report leaves open.

The server now publishes it. The profile's targets carried one IPv4 and a
TODO; it reports both families and both alternates, derived from the UDP
listen spec rather than configured separately, so the list cannot drift
from what is actually bound. No reservation means no alternate is
claimed: announcing a second address as the RFC 5780 alternate when none
was set aside would promise a redirect the server will not send.

The app renders them read-only, in a panel visibly distinct from the
fields above. An editable box that changes nothing is worse than no box,
and these are facts to read rather than settings to apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 08:14:42 +02:00
co-authored by Claude Opus 5
parent fe4ec23ba1
commit c4f2a10790
8 changed files with 207 additions and 8 deletions
@@ -92,6 +92,7 @@ class RunStore(context: Context, private val settings: Settings) {
val profile = client().profile(settings.serverCredential)
// Learned here so the next run's canary probe knows what to ask for.
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
settings.serverFacts = describeFacts(profile)
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
val head = "${profile.name} · server ${profile.serverVersion} · " +
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
@@ -117,6 +118,32 @@ class RunStore(context: Context, private val settings: Settings) {
* no credential — fails later, somewhere else, with an error that points at the wrong thing.
* Blocking; callers run it off the main thread.
*/
/**
* Renders what the server says about itself, for display.
*
* Only what a person measuring against it would want to check: which addresses the tests will
* actually use, on which ports, and what the server admits it can do. Addresses first, because
* "which address did this result come from" is the question a report leaves open.
*/
private fun describeFacts(p: app.echo_lot.protocol.Profile): String {
val lines = ArrayList<String>()
lines += "${p.name} · server ${p.serverVersion}"
for (t in p.targets) {
t.ip4?.let { lines += "IPv4 $it" }
t.ip4Alt?.let { lines += "IPv4 $it (alternate, for NAT behaviour tests)" }
t.ip6?.let { lines += "IPv6 $it" }
t.ip6Alt?.let { lines += "IPv6 $it (alternate, for NAT behaviour tests)" }
lines += "ports udp ${t.udpPort} · tcp ${t.tcpPort} · stun ${t.stunPort}"
}
if (p.canaryZone.isNotBlank()) lines += "DNS canary zone ${p.canaryZone}"
if (p.capabilities.isNotEmpty()) {
lines += "can measure ${p.capabilities.joinToString(", ")}"
}
val b = StringBuilder(lines.joinToString("\n"))
return b.toString().trimEnd()
}
fun enroll(link: String, deviceName: String?): String {
val parsed = app.echo_lot.protocol.EnrollmentLink.parse(link)
?: return "That does not look like an Echolot enrollment link. It should start with " +
@@ -124,6 +151,8 @@ class RunStore(context: Context, private val settings: Settings) {
return try {
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER)
settings.serverFacts = describeFacts(enrolled.profile)
enrolled.profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
settings.serverUrl = enrolled.controlUrl
settings.serverPublicUrl = enrolled.publicUrl
settings.serverPin = enrolled.pin
@@ -154,6 +183,7 @@ class RunStore(context: Context, private val settings: Settings) {
val client = client()
val profile = client.profile(settings.serverCredential)
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
settings.serverFacts = describeFacts(profile)
// Compatibility before policy: an incompatible server may well advertise an upload
// policy it would never actually apply to us.
@@ -108,6 +108,17 @@ class Settings(context: Context) {
get() = (prefs.getString(SERVER_PUBLIC_URL, "") ?: "").ifBlank { serverUrl }
set(v) = prefs.edit().putString(SERVER_PUBLIC_URL, v.trim()).apply()
/**
* What the server said about itself, last time it was asked: addresses, ports, capabilities.
*
* Cached as a rendered block rather than as fields, because it is shown and never acted on —
* these are facts to read, not settings to apply, and storing them as settings would invite
* exactly the confusion of an editable box that changes nothing.
*/
var serverFacts: String
get() = prefs.getString(SERVER_FACTS, "") ?: ""
set(v) = prefs.edit().putString(SERVER_FACTS, v).apply()
var serverCredential: String
get() = prefs.getString(SERVER_CRED, "") ?: ""
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
@@ -184,6 +195,7 @@ class Settings(context: Context) {
const val SERVER_PIN = "server_pin"
const val SERVER_CRED = "server_credential"
const val SERVER_PUBLIC_URL = "server_public_url"
const val SERVER_FACTS = "server_facts"
const val CANARY_ZONE = "server_canary_zone"
const val PENDING_VERIFIER = "pending_auth_verifier"
const val PENDING_STATE = "pending_auth_state"
@@ -11,6 +11,7 @@ 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.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -19,6 +20,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -76,7 +78,9 @@ fun SettingsScreen(
// Enrolling is asynchronous, so these are re-read when its result lands rather than when the
// button is pressed — reading them immediately showed the previous server's values and looked
// exactly like an enrollment that had silently done nothing.
androidx.compose.runtime.LaunchedEffect(enrollStatus) {
var serverFacts by remember { mutableStateOf(settings.serverFacts) }
androidx.compose.runtime.LaunchedEffect(enrollStatus, serverStatus) {
serverFacts = settings.serverFacts
serverUrl = settings.serverPublicUrl
serverPin = settings.serverPin
serverCred = settings.serverCredential
@@ -251,6 +255,28 @@ fun SettingsScreen(
},
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
// What the server reports about itself. Read-only on purpose: these are facts to
// check, not settings to apply, and an editable box that changes nothing is worse
// than no box at all.
if (serverFacts.isNotBlank()) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"What this server reports",
style = MaterialTheme.typography.labelLarge,
)
Text(
serverFacts,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
}
if (settings.serverUrl.isNotBlank() && settings.serverUrl != settings.serverPublicUrl) {
Text(
"Connects to ${settings.serverUrl} — this server publishes one name and " +
@@ -29,6 +29,15 @@ data class Target(
val id: String,
val ip4: String? = null,
val ip6: String? = null,
/**
* The second address, which RFC 5780 behaviour discovery redirects to.
*
* Worth surfacing rather than treating as an implementation detail: a report that says "the
* server did not answer" means something different depending on which of its addresses was
* asked, and an operator reading one needs to be able to tell.
*/
@SerialName("ip4_alt") val ip4Alt: String? = null,
@SerialName("ip6_alt") val ip6Alt: String? = null,
@SerialName("udp_port") val udpPort: Int = 0,
@SerialName("tcp_port") val tcpPort: Int = 0,
@SerialName("stun_port") val stunPort: Int = 0,