package com.sptmobile.pairing import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** * One paired host in the user-ordered dial list (DESIGN.md rulings 3/10). * Identity is the iroh [node] key — endpoint ids may legitimately repeat * across hosts (each host names its own Mobile Gateway endpoint), node keys * cannot. [hostVersion] is what `pair` reported, kept for the UI. * * [star] is THIS host's star endpoint id (ruling 5: per-paired-host star, * phone-local — voice routes to the first REACHABLE host's star). Null = * no star chosen; this host is then not a voice-forward candidate. */ // [impl->REQ-STAR-PER-HOST] @Serializable data class PairedHost( val node: String, val endpoint: String, val token: String, val hostVersion: String? = null, val star: String? = null, ) { /** What the pairing screen shows when the full node key is too long. */ val nodeShort: String get() = if (node.length <= 12) node else node.take(12) + "…" fun toQrPayload(): QrPayload = QrPayload(v = QrPayload.SUPPORTED_VERSION, node = node, endpoint = endpoint, token = token) } // [impl->REQ-MULTI-HOST-PAIRING] /** * Pure ops on the user-ordered host list. List index IS the dial priority * (ruling 10: ordered dial list; ruling 5: hosts user-ordered). Kept free of * Android/DataStore so the ordering semantics are JVM-unit-testable; the * DataStore wiring in [HostStore] just persists what these return. */ object PairedHostList { private val json = Json { ignoreUnknownKeys = true } /** * Re-pairing a host already in the list (same node key) updates it IN * PLACE — fresh token/version, same dial priority, and the existing * star survives (the QR payload never carries one; a re-pair must not * silently un-star a host — REQ-STAR-PER-HOST). New hosts append at * the end (lowest priority; the user promotes explicitly). */ fun upsert(hosts: List, host: PairedHost): List { val at = hosts.indexOfFirst { it.node == host.node } return if (at < 0) hosts + host else hosts.toMutableList().apply { this[at] = host.copy(star = this[at].star) } } // [impl->REQ-STAR-PER-HOST] /** Set (or clear, star=null) one host's star endpoint. */ fun setStar(hosts: List, node: String, star: String?): List = hosts.map { if (it.node == node) it.copy(star = star) else it } fun remove(hosts: List, node: String): List = hosts.filterNot { it.node == node } /** Move a host by [delta] positions, clamped to the list bounds. */ fun move(hosts: List, node: String, delta: Int): List { val from = hosts.indexOfFirst { it.node == node } if (from < 0) return hosts val to = (from + delta).coerceIn(0, hosts.lastIndex) if (to == from) return hosts return hosts.toMutableList().apply { add(to, removeAt(from)) } } fun encode(hosts: List): String = json.encodeToString(hosts) /** Malformed persisted JSON degrades to an empty list, never a crash. */ fun decode(text: String): List = try { json.decodeFromString(text) } catch (_: IllegalArgumentException) { emptyList() } }