feat(companion): saved servers keyed by node npub — pairing contract item 1

Re-scanning a node after a LAN renumber updated the FIPS peer (npub-
matched) but duplicated the saved-server entry (address-matched).
ServerEntry now carries the node's fnpub as a trailing serialization
field (legacy entries still parse) and every saved/active-entry match
goes through sameNode: npub identity first, address/port/scheme only as
the LAN-only fallback. Edit forms that don't carry the npub keep the
stored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-23 21:47:10 +01:00
parent 5d2c28da7b
commit bb4166954a
3 changed files with 66 additions and 44 deletions

View File

@ -21,6 +21,10 @@ data class ServerEntry(
val name: String = "",
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
val meshIp: String = "",
/** Node's FIPS npub the durable identity. When present it, not the
* address, is what identifies the entry: FIPS peers on npubs, IPs are
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
val npub: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
@ -45,9 +49,15 @@ data class ServerEntry(
fun toMeshUrl(): String? =
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
// name/meshIp are trailing fields so entries saved before they existed
// (4/5 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp"
// name/meshIp/npub are trailing fields so entries saved before they
// existed (4/5/6 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
/** Same node as [other]? npub identity wins; address/port/scheme is the
* fallback for LAN-only entries that never advertised FIPS. */
fun sameNode(other: ServerEntry): Boolean =
(npub.isNotBlank() && npub == other.npub) ||
(address == other.address && port == other.port && useHttps == other.useHttps)
companion object {
fun deserialize(raw: String): ServerEntry? {
@ -60,6 +70,7 @@ data class ServerEntry(
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
meshIp = parts.getOrElse(5) { "" },
npub = parts.getOrElse(6) { "" },
)
}
}
@ -73,6 +84,7 @@ class ServerPreferences(private val context: Context) {
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
private val activeNpubKey = stringPreferencesKey("active_npub")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
@ -86,6 +98,7 @@ class ServerPreferences(private val context: Context) {
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
meshIp = prefs[activeMeshIpKey] ?: "",
npub = prefs[activeNpubKey] ?: "",
)
}
@ -111,6 +124,7 @@ class ServerPreferences(private val context: Context) {
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
prefs[activeMeshIpKey] = server.meshIp
prefs[activeNpubKey] = server.npub
}
addSavedServer(server)
}
@ -123,6 +137,7 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
prefs.remove(activeMeshIpKey)
prefs.remove(activeNpubKey)
}
}
@ -134,65 +149,66 @@ class ServerPreferences(private val context: Context) {
}
/**
* Replace a saved server in place. Matches the existing entry by connection
* identity (address/port/scheme) so edits that change the name or password
* or that touch a legacy 4-field entry still update the right record. If the
* edited server is also the active one, the active record is kept in sync.
* Replace a saved server in place. Matches the existing entry by node
* identity npub first, address/port/scheme as the LAN-only fallback
* (ServerEntry.sameNode) so edits that change the name, password or even
* every address still update the right record. An edit form that doesn't
* carry the npub keeps the stored one. If the edited server is also the
* active one, the active record is kept in sync.
*/
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == original.address &&
e.port == original.port &&
e.useHttps == original.useHttps
ServerEntry.deserialize(raw)?.sameNode(original) == true
}.toSet()
prefs[savedServersKey] = filtered + updated.serialize()
prefs[savedServersKey] = filtered + toStore.serialize()
val isActive = prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
val activeNpub = prefs[activeNpubKey] ?: ""
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
(
prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
)
if (isActive) {
prefs[activeAddressKey] = updated.address
prefs[activeHttpsKey] = updated.useHttps
prefs[activePortKey] = updated.port
prefs[activePasswordKey] = updated.password
prefs[activeNameKey] = updated.name
prefs[activeMeshIpKey] = updated.meshIp
prefs[activeAddressKey] = toStore.address
prefs[activeHttpsKey] = toStore.useHttps
prefs[activePortKey] = toStore.port
prefs[activePasswordKey] = toStore.password
prefs[activeNameKey] = toStore.name
prefs[activeMeshIpKey] = toStore.meshIp
prefs[activeNpubKey] = toStore.npub
}
}
}
/**
* Add a server, or update the entry with the same connection identity
* (address/port/scheme) used by QR pairing so re-scanning a node never
* duplicates it. A blank incoming password/name keeps the stored value
* (a real node's QR never carries the password). Returns the merged entry.
* Add a server, or update the entry for the same node npub first,
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode)
* used by QR pairing so re-scanning a node never duplicates it, even after
* the LAN renumbered and every address changed (npub-first contract in
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
* stored value (a real node's QR never carries the password). Returns the
* merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
it.address == server.address &&
it.port == server.port &&
it.useHttps == server.useHttps
}
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
.firstOrNull { it.sameNode(server) }
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
meshIp = server.meshIp.ifBlank { existing.meshIp },
npub = server.npub.ifBlank { existing.npub },
)
}
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(merged) == true
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
@ -202,15 +218,11 @@ class ServerPreferences(private val context: Context) {
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
// Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
// Match by node identity (npub, else address/port/scheme) rather
// than the exact serialized string, so a rename — or a legacy
// short-format entry — still removes the right record.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(server) == true
}.toSet()
}
}

View File

@ -78,6 +78,9 @@ object ServerQrParser {
password = credential,
name = uri.getQueryParameter("name") ?: "",
meshIp = fips?.ula ?: "",
// npub is the durable identity — saved-server upserts match on
// it, so re-scanning after a LAN renumber updates in place.
npub = fips?.npub ?: "",
),
fips = fips,
)

View File

@ -112,6 +112,13 @@ broke as soon as the LAN renumbered or the phone left home. FIPS peers on
the vps2 public anchor, and the web UI prepends the node's self-anchor to
`fanchors`.)
App-side status: items 2/3 were already covered by `FipsPreferences.
upsertNodePeer` (npub-matched peers, self-anchor dedup) and item 4 by the
WebView's `meshFallbackUrl` retry. Item 1 shipped 2026-07-23: `ServerEntry`
carries `npub` (trailing serialization field, legacy entries still parse) and
`ServerPreferences` matches saved/active entries via `sameNode` — npub first,
address/port/scheme only as the LAN-only fallback.
## Testing checklist (app side)
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.