|
|
|
@@ -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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|