package com.sptmobile.browse import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext // [impl->REQ-BROWSER-LIVE] /** * Browse refresh: ask every host with a live link ([LinkService]'s * supervisor) for its endpoint listing, then union the views with * [EndpointDirectory.merge]. Hosts without a live link contribute nothing — * the merged view is what the phone can currently reach, which is exactly * what "live" means here (ruling 9). One broken host degrades to a note, * never an empty screen. * * "Live" is reactive, not snapshot: any host link-state flip re-lists, a * periodic tick re-lists while the Browse tab is visible ([setVisible]), * and the ruling-9 overlay ([EndpointDirectory.overlayGatewayLiveness]) * recolors gateway instance rows the instant the device link moves — even * between listings. */ @OptIn(ExperimentalCoroutinesApi::class) class BrowseViewModel(app: Application) : AndroidViewModel(app) { private val store = HostStore(app) /** Raw merged listing from the last refresh (pre-overlay). */ private val _listing = MutableStateFlow>(emptyList()) private val linkStates = LinkService.shared .flatMapLatest { it?.states ?: flowOf(emptyMap()) } /** * The browse view: last listing with ruling-9 gateway liveness overlaid, * regrouped BY NODE → `/` → endpoint (operator ruling 2026-07-07). */ val nodes: StateFlow> = combine(_listing, store.hosts, linkStates) { listing, hosts, links -> // The machines running the phone's paired gateways sort to the top. EndpointDirectory.groupByNode( EndpointDirectory.overlayGatewayLiveness(listing, hosts, links), homeEndpointIds = hosts.mapTo(mutableSetOf()) { it.endpoint }, ) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) private val _refreshing = MutableStateFlow(false) val refreshing: StateFlow = _refreshing /** One-line refresh outcome ("2 hosts · host-x unreachable"). */ private val _message = MutableStateFlow(null) val message: StateFlow = _message private val visible = MutableStateFlow(false) init { // Re-list whenever the set of CONNECTED hosts changes (a link coming // up brings its endpoints in; one going down takes them out). Keyed // on the connected set, not the raw state map — backoff countdowns // must not spam the hosts. viewModelScope.launch { linkStates .map { states -> states.filterValues { it is HostLinkState.Connected }.keys } .distinctUntilChanged() .collect { refresh() } } // Periodic re-list while the Browse tab is on screen; registry-side // changes (new endpoints, status flips) have no push channel, so the // tab polls. Leaving the tab stops the tick. viewModelScope.launch { visible.collectLatest { onScreen -> // Refresh-on-entry first: coming back to the tab must not // wait out a tick interval. while (onScreen) { refresh() delay(RELIST_INTERVAL_MS) } } } } /** The Browse tab reports its visibility; the re-list tick follows it. */ fun setVisible(onScreen: Boolean) { visible.value = onScreen } fun refresh() { if (_refreshing.value) return viewModelScope.launch { _refreshing.value = true try { _message.value = doRefresh() } finally { _refreshing.value = false } } } private suspend fun doRefresh(): String { val supervisor = LinkService.shared.value ?: return "link service not running" val hosts = store.hosts.first() if (hosts.isEmpty()) return "no hosts paired" val states = supervisor.states.value val replies = mutableListOf>>() val problems = mutableListOf() // Store order IS dial priority — merge conflicts resolve to the host // we would actually dial. for (host in hosts) { val state = states[host.node] if (state !is HostLinkState.Connected) { problems += "${host.endpoint}: not linked" continue } try { val raw = withContext(LinkIo.dispatcher) { LinkNative.listEndpoints(state.handle) } // Learn this host's gateway spt-node label (for the Hosts / // Messages `endpoint @ LABEL` chrome), from its own listing. NodeLabels.resolve(host.node, host.endpoint, raw) replies += host.node to EndpointDirectory.parseHostReply(raw) } catch (e: RuntimeException) { problems += "${host.endpoint}: ${e.message}" // [impl->REQ-HAZARD-STALE-LINK-STALL] listEndpoints threw on a // Connected handle — report it stale so the supervisor redials // now instead of leaving a dead handle showing "up". supervisor.reportStale(host.node, state.handle, "listEndpoints: ${e.message}") } } _listing.value = EndpointDirectory.merge(replies) val ok = "${replies.size}/${hosts.size} hosts answered" return if (problems.isEmpty()) ok else "$ok · " + problems.joinToString(" · ") } private companion object { const val RELIST_INTERVAL_MS = 30_000L } }