package com.sptmobile.endpoint import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.sptmobile.browse.BrowsedInstance import com.sptmobile.browse.EndpointDirectory import com.sptmobile.link.DialList import com.sptmobile.link.FollowEvent import com.sptmobile.link.HostLinkState import com.sptmobile.link.LinkIo import com.sptmobile.link.LinkNative import com.sptmobile.link.LinkService import com.sptmobile.pairing.HostStore import com.sptmobile.pairing.PairedHost import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID /** Where this endpoint's digest is coming from (DESIGN.md ruling 4). */ sealed interface DigestRoute { data object Resolving : DigestRoute /** A co-located paired host serves the digest; [hostEndpoint] names it. */ data class Direct(val hostEndpoint: String) : DigestRoute /** * No linked host is co-located with the target: no digest until one is * (or spt-core grows cross-node digests) — the screen shows the registry * card [instances] instead. History + send still work. */ data class PendingCrossNode(val instances: List) : DigestRoute } // [impl->REQ-ENDPOINT-VIEW-INTERLACE] /** * One endpoint's interlaced view: history fetch + digest snapshot at open, * then a long-lived follow reader (APP-PLAN Q2 — one coroutine polling * `followNext`), all RPCs through the [LinkService] supervisor's Connected * handles on [LinkIo.dispatcher]. The pure merge/dedup semantics live in * [Timeline] and [DigestViewState]; the pure routing rule in [DigestRouter]; * this class only moves data. * * W5 host choice: history is the UNION across linked hosts (ruling 8), the * digest comes from the first CO-LOCATED linked host only (ruling 4), and * send walks the ordered dial list ([DialList], ruling 10). */ class EndpointViewModel( app: Application, private val endpointId: String, ) : AndroidViewModel(app) { private val store = HostStore(app) private val view = DigestViewState() /** History axis: last union fetch + locally-appended own sends. */ private var fetched: List = emptyList() private val localSends = mutableListOf() private val _rows = MutableStateFlow>(emptyList()) val rows: StateFlow> = _rows /** One-line connection/stream status for the screen header. */ private val _status = MutableStateFlow("opening…") val status: StateFlow = _status private val _route = MutableStateFlow(DigestRoute.Resolving) val route: StateFlow = _route private val _sending = MutableStateFlow(false) val sending: StateFlow = _sending /** Send failures only — a successful send just appears in the timeline. */ private val _sendError = MutableStateFlow(null) val sendError: StateFlow = _sendError // [impl->REQ-HAZARD-DIGEST-CARD-COLLAPSE] /** * Expanded digest-input cards, keyed by [TimelineRow.stableKey]. Hoisted * OUT of the item composable so an idle-tick republish (the follow liveness * belt re-resyncs every timeout) can never collapse an open card — the * item scope is disposable, this survives it. */ private val _expanded = MutableStateFlow>(emptySet()) val expanded: StateFlow> = _expanded fun toggleExpanded(key: String) { _expanded.value = _expanded.value.toMutableSet().apply { if (!add(key)) remove(key) } } private var started = false fun start() { if (started) return started = true viewModelScope.launch { followLoop() } } // [impl->REQ-MULTI-HOST-PAIRING] /** * Compose + send, walking the ordered dial list ([DialList]): a host * whose link fails mid-RPC is skipped for the next linked host; ONE * msg-id rides the whole walk, so even the pathological * sent-but-reply-lost host leaves the exact-dedup axis intact. * * The first host that ANSWERS ends the walk. `success: true` (SENT or * QUEUED) is FINAL — the row is appended locally and NEVER re-sent * (REQ-HAZARD-QUEUED-RETRY); its digest echo collapses by the msg-id * riding `--json-payload` (REQ-HAZARD-DUP-ROWS). `success: false` is a * REACHABLE host refusing (target-level, e.g. no such perch) — surfaced * as the send error, never replayed against a lower-priority host. */ fun send(body: String) { val text = body.trim() if (text.isEmpty() || _sending.value) return viewModelScope.launch { _sending.value = true _sendError.value = null try { val supervisor = LinkService.shared.value ?: throw RuntimeException("link service not running") val hosts = store.hosts.first() val states = supervisor.states.value val msgId = UUID.randomUUID().toString() // Note BEFORE the send: the echo can arrive on the follow // stream before the JNI call even returns. view.noteOwnSend(msgId) val (host, outcome) = DialList.walk(hosts, states) { host, handle -> val reply = withContext(LinkIo.dispatcher) { LinkNative.send( handle, endpointId, host.endpoint, """{"msg-id":"$msgId"}""", text, ) } host to Timeline.parseSendResult(reply) } if (!outcome.success) { throw RuntimeException("send ${outcome.outcome}") } localSends += HistoryRow( msg_id = msgId, ts_ms = System.currentTimeMillis(), dir = "out", from = host.endpoint, body = text, ) publish() } catch (e: RuntimeException) { if (e is CancellationException) throw e _sendError.value = e.message ?: "send failed" } finally { _sending.value = false } } } // [impl->REQ-HAZARD-STALE-LINK-STALL] /** Tell the supervisor an RPC threw on a Connected handle so it redials * now (guarded on handle-equality — a superseded handle is a no-op). */ private fun reportStale(node: String, handle: Long, error: String) { LinkService.shared.value?.reportStale(node, handle, error) } /** Linked hosts in dial order (empty while the service is down). */ private suspend fun linkedHosts(): List> { val supervisor = LinkService.shared.value ?: return emptyList() return DialList.linked(store.hosts.first(), supervisor.states.value) } /** * The view lifetime: resolve the linked hosts, load the history union + * pick the digest route, then pump the follow stream from the co-located * host. Cursor mechanics per DESIGN.md §Interlaced view mechanics: * deltas apply truncate-to-`from`+append; an overshooting delta re-syncs * via `digestSnapshot(after = cursor)`; `end`/`error` re-opens the stream * the same way. No co-located host → registry card + periodic re-check * (a host may come up or the endpoint may move). Any dead handle falls * back to the supervisor (which is already redialing) with a short wait. */ private suspend fun followLoop() { while (viewModelScope.isActive) { val linked = linkedHosts() if (linked.isEmpty()) { _status.value = "waiting for a linked host…" delay(RETRY_MS) continue } var digestHandle: Pair? = null try { _status.value = "loading…" // Paint history immediately — the digest can be slow to route on // this link, and blanking the whole view until it lands is worse // than the churn it avoided. The digest inserting later no longer // jumps the view: rows carry stable (turn, ordinal) keys, so // LazyColumn holds the reader's anchor when the digest streams in. refreshHistory(linked) publish() // [impl->REQ-DIGEST-DIRECT-ROUTE] // Fast path: a cached route for this endpoint (still linked) // skips the ~2s listEndpoints routing round-trip, so a repeat // open paints the digest almost immediately. `endpoint list` // dominates first-open latency; the route rarely changes, and a // stale cache self-heals (invalidated in the catch below). val cached = DigestRouteCache.get(endpointId) ?.takeIf { node -> linked.any { it.first.node == node } } val digestNode = cached ?: run { val listings = fetchListings(linked) val chosen = DigestRouter.choose(endpointId, listings) if (chosen == null) { _route.value = DigestRoute.PendingCrossNode( DigestRouter.registryCard(endpointId, listings) ) _status.value = "digest pending cross-node" delay(PENDING_RECHECK_MS) return@run null } DigestRouteCache.put(endpointId, chosen) chosen } ?: continue val (host, handle) = linked.first { it.first.node == digestNode } _route.value = DigestRoute.Direct(host.endpoint) digestHandle = host.node to handle resync(handle) publish() pump(handle) } catch (e: RuntimeException) { if (e is CancellationException) throw e // A cached route that failed may be stale — drop it so the next // pass re-routes from a fresh listing instead of looping on a // dead choice. DigestRouteCache.invalidate(endpointId) // [impl->REQ-HAZARD-STALE-LINK-STALL] The digest RPC (resync / // follow) threw on the Connected digest handle — report it stale // so the supervisor redials now; the loop re-resolves off the // fresh link instead of retrying the dead handle. digestHandle?.let { (node, handle) -> reportStale(node, handle, "digest: ${e.message}") } _status.value = "link lost: ${e.message ?: "error"} — retrying" delay(RETRY_MS) } } } // [impl->REQ-MULTI-HOST-PAIRING] /** * History is host-side per host; the phone's view is the UNION across * linked hosts (ruling 8 — a send routed through host B lives in B's * log). Union in dial order, msg-id collapse in [Timeline.interlace]; * a host that fails contributes nothing this round (one host down ≠ * empty view) and only every-host-failed throws. */ private suspend fun refreshHistory(linked: List>) { val union = mutableListOf() var answered = 0 for ((host, handle) in linked) { try { union += withContext(LinkIo.dispatcher) { Timeline.parseHistory( LinkNative.historyFetch(handle, endpointId, HISTORY_LIMIT) ) } answered++ } catch (e: RuntimeException) { if (e is CancellationException) throw e reportStale(host.node, handle, "historyFetch: ${e.message}") } } if (answered == 0) throw RuntimeException("history: no linked host answered") fetched = union } /** Per-host listings in dial order; a host that fails is skipped. */ private suspend fun fetchListings( linked: List>, ): List = linked.mapNotNull { (host, handle) -> try { val raw = withContext(LinkIo.dispatcher) { LinkNative.listEndpoints(handle) } com.sptmobile.browse.NodeLabels.resolve(host.node, host.endpoint, raw) DigestRouter.HostListing( hostNode = host.node, localIds = EndpointDirectory.parseLocalIds(raw), subnets = EndpointDirectory.parseHostReply(raw), ) } catch (e: RuntimeException) { if (e is CancellationException) throw e reportStale(host.node, handle, "listEndpoints: ${e.message}") null } } /** Snapshot re-sync at the current cursor (`after < 0` = first load). */ private suspend fun resync(handle: Long) { val after = view.cursor() ?: -1L val snapshot = withContext(LinkIo.dispatcher) { LinkNative.digestSnapshot(handle, endpointId, after) } view.applySnapshot(snapshot) } /** One open follow stream, pumped until it dies; throws to re-resolve. */ private suspend fun pump(handle: Long) { val followHandle = withContext(LinkIo.dispatcher) { LinkNative.digestFollow(handle, endpointId) } try { _status.value = "live" while (viewModelScope.isActive) { val envelope = withContext(LinkIo.dispatcher) { LinkNative.followNext(followHandle, FOLLOW_TIMEOUT_MS) } when (val event = FollowEvent.decode(envelope)) { is FollowEvent.Delta -> { // Just accumulate the delta's turns (the view is an // append-only log now — no gap handling needed). A delta // may also mean an inbound reply landed in host history. view.applyDelta(event.deltaJson) refreshHistory(linkedHosts()) publish() } FollowEvent.Timeout -> { // Liveness belt (field bug 2026-07-06: the view froze // after its first load). The resync just accumulates into // the log — idempotent, so it can never oscillate the // length; a dead handle throws into followLoop's retry. refreshHistory(linkedHosts()) resync(handle) publish() } FollowEvent.End -> throw RuntimeException("follow stream ended") is FollowEvent.Error -> throw RuntimeException(event.message) } } } finally { withContext(LinkIo.dispatcher) { try { LinkNative.followClose(followHandle) } catch (_: RuntimeException) { // Stream is being abandoned either way. } } } } private fun publish() { _rows.value = Timeline.interlace(fetched, localSends, view.turns, view.ownSends()) } companion object { private const val HISTORY_LIMIT = 200 private const val FOLLOW_TIMEOUT_MS = 15_000L private const val RETRY_MS = 2_000L /** Pending cross-node re-checks the routing table, not a dead link — * slower cadence, listEndpoints per linked host each check. */ private const val PENDING_RECHECK_MS = 15_000L fun factory(app: Application, endpointId: String) = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = EndpointViewModel(app, endpointId) as T } } }