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:
parent
5d2c28da7b
commit
bb4166954a
@ -21,6 +21,10 @@ data class ServerEntry(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
|
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
|
||||||
val meshIp: String = "",
|
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. */
|
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||||
fun displayName(): String = name.ifBlank { address }
|
fun displayName(): String = name.ifBlank { address }
|
||||||
@ -45,9 +49,15 @@ data class ServerEntry(
|
|||||||
fun toMeshUrl(): String? =
|
fun toMeshUrl(): String? =
|
||||||
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
|
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
|
||||||
|
|
||||||
// name/meshIp are trailing fields so entries saved before they existed
|
// name/meshIp/npub are trailing fields so entries saved before they
|
||||||
// (4/5 fields) still deserialize, defaulting to "".
|
// existed (4/5/6 fields) still deserialize, defaulting to "".
|
||||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp"
|
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 {
|
companion object {
|
||||||
fun deserialize(raw: String): ServerEntry? {
|
fun deserialize(raw: String): ServerEntry? {
|
||||||
@ -60,6 +70,7 @@ data class ServerEntry(
|
|||||||
password = parts.getOrElse(3) { "" },
|
password = parts.getOrElse(3) { "" },
|
||||||
name = parts.getOrElse(4) { "" },
|
name = parts.getOrElse(4) { "" },
|
||||||
meshIp = parts.getOrElse(5) { "" },
|
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 activePasswordKey = stringPreferencesKey("active_password")
|
||||||
private val activeNameKey = stringPreferencesKey("active_name")
|
private val activeNameKey = stringPreferencesKey("active_name")
|
||||||
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
||||||
|
private val activeNpubKey = stringPreferencesKey("active_npub")
|
||||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||||
@ -86,6 +98,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
password = prefs[activePasswordKey] ?: "",
|
password = prefs[activePasswordKey] ?: "",
|
||||||
name = prefs[activeNameKey] ?: "",
|
name = prefs[activeNameKey] ?: "",
|
||||||
meshIp = prefs[activeMeshIpKey] ?: "",
|
meshIp = prefs[activeMeshIpKey] ?: "",
|
||||||
|
npub = prefs[activeNpubKey] ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +124,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[activePasswordKey] = server.password
|
prefs[activePasswordKey] = server.password
|
||||||
prefs[activeNameKey] = server.name
|
prefs[activeNameKey] = server.name
|
||||||
prefs[activeMeshIpKey] = server.meshIp
|
prefs[activeMeshIpKey] = server.meshIp
|
||||||
|
prefs[activeNpubKey] = server.npub
|
||||||
}
|
}
|
||||||
addSavedServer(server)
|
addSavedServer(server)
|
||||||
}
|
}
|
||||||
@ -123,6 +137,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs.remove(activePasswordKey)
|
prefs.remove(activePasswordKey)
|
||||||
prefs.remove(activeNameKey)
|
prefs.remove(activeNameKey)
|
||||||
prefs.remove(activeMeshIpKey)
|
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
|
* Replace a saved server in place. Matches the existing entry by node
|
||||||
* identity (address/port/scheme) so edits that change the name or password —
|
* identity — npub first, address/port/scheme as the LAN-only fallback
|
||||||
* or that touch a legacy 4-field entry — still update the right record. If the
|
* (ServerEntry.sameNode) — so edits that change the name, password or even
|
||||||
* edited server is also the active one, the active record is kept in sync.
|
* 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) {
|
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
||||||
|
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
val filtered = current.filterNot { raw ->
|
val filtered = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(original) == true
|
||||||
e != null &&
|
|
||||||
e.address == original.address &&
|
|
||||||
e.port == original.port &&
|
|
||||||
e.useHttps == original.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
prefs[savedServersKey] = filtered + updated.serialize()
|
prefs[savedServersKey] = filtered + toStore.serialize()
|
||||||
|
|
||||||
val isActive = prefs[activeAddressKey] == original.address &&
|
val activeNpub = prefs[activeNpubKey] ?: ""
|
||||||
(prefs[activePortKey] ?: "") == original.port &&
|
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
|
||||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
(
|
||||||
|
prefs[activeAddressKey] == original.address &&
|
||||||
|
(prefs[activePortKey] ?: "") == original.port &&
|
||||||
|
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||||
|
)
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
prefs[activeAddressKey] = updated.address
|
prefs[activeAddressKey] = toStore.address
|
||||||
prefs[activeHttpsKey] = updated.useHttps
|
prefs[activeHttpsKey] = toStore.useHttps
|
||||||
prefs[activePortKey] = updated.port
|
prefs[activePortKey] = toStore.port
|
||||||
prefs[activePasswordKey] = updated.password
|
prefs[activePasswordKey] = toStore.password
|
||||||
prefs[activeNameKey] = updated.name
|
prefs[activeNameKey] = toStore.name
|
||||||
prefs[activeMeshIpKey] = updated.meshIp
|
prefs[activeMeshIpKey] = toStore.meshIp
|
||||||
|
prefs[activeNpubKey] = toStore.npub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a server, or update the entry with the same connection identity
|
* Add a server, or update the entry for the same node — npub first,
|
||||||
* (address/port/scheme) — used by QR pairing so re-scanning a node never
|
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
|
||||||
* duplicates it. A blank incoming password/name keeps the stored value
|
* used by QR pairing so re-scanning a node never duplicates it, even after
|
||||||
* (a real node's QR never carries the password). Returns the merged entry.
|
* 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 {
|
suspend fun upsertServer(server: ServerEntry): ServerEntry {
|
||||||
var merged = server
|
var merged = server
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
|
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
|
||||||
it.address == server.address &&
|
.firstOrNull { it.sameNode(server) }
|
||||||
it.port == server.port &&
|
|
||||||
it.useHttps == server.useHttps
|
|
||||||
}
|
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
merged = server.copy(
|
merged = server.copy(
|
||||||
password = server.password.ifBlank { existing.password },
|
password = server.password.ifBlank { existing.password },
|
||||||
name = server.name.ifBlank { existing.name },
|
name = server.name.ifBlank { existing.name },
|
||||||
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
||||||
|
npub = server.npub.ifBlank { existing.npub },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val filtered = current.filterNot { raw ->
|
val filtered = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(merged) == true
|
||||||
e != null &&
|
|
||||||
e.address == server.address &&
|
|
||||||
e.port == server.port &&
|
|
||||||
e.useHttps == server.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
prefs[savedServersKey] = filtered + merged.serialize()
|
prefs[savedServersKey] = filtered + merged.serialize()
|
||||||
}
|
}
|
||||||
@ -202,15 +218,11 @@ class ServerPreferences(private val context: Context) {
|
|||||||
suspend fun removeSavedServer(server: ServerEntry) {
|
suspend fun removeSavedServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
// Match by connection identity (address/port/scheme) rather than the
|
// Match by node identity (npub, else address/port/scheme) rather
|
||||||
// exact serialized string, so a rename — or the legacy 4-field format
|
// than the exact serialized string, so a rename — or a legacy
|
||||||
// saved before names existed — still removes the right entry.
|
// short-format entry — still removes the right record.
|
||||||
prefs[savedServersKey] = current.filterNot { raw ->
|
prefs[savedServersKey] = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(server) == true
|
||||||
e != null &&
|
|
||||||
e.address == server.address &&
|
|
||||||
e.port == server.port &&
|
|
||||||
e.useHttps == server.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,9 @@ object ServerQrParser {
|
|||||||
password = credential,
|
password = credential,
|
||||||
name = uri.getQueryParameter("name") ?: "",
|
name = uri.getQueryParameter("name") ?: "",
|
||||||
meshIp = fips?.ula ?: "",
|
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,
|
fips = fips,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
the vps2 public anchor, and the web UI prepends the node's self-anchor to
|
||||||
`fanchors`.)
|
`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)
|
## Testing checklist (app side)
|
||||||
|
|
||||||
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
|
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user