From bb4166954a9b44d00a89b376418eb24028e09f9c Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 21:47:10 +0100 Subject: [PATCH] =?UTF-8?q?feat(companion):=20saved=20servers=20keyed=20by?= =?UTF-8?q?=20node=20npub=20=E2=80=94=20pairing=20contract=20item=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../archipelago/app/data/ServerPreferences.kt | 100 ++++++++++-------- .../archipelago/app/data/ServerQrParser.kt | 3 + docs/companion-pairing-qr.md | 7 ++ 3 files changed, 66 insertions(+), 44 deletions(-) diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt index f4ff0c17..6d0458aa 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -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() } } diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt index 56698b20..1af28b16 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt @@ -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, ) diff --git a/docs/companion-pairing-qr.md b/docs/companion-pairing-qr.md index ff0f4a85..a5290f1d 100644 --- a/docs/companion-pairing-qr.md +++ b/docs/companion-pairing-qr.md @@ -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.