From 8f0d73d89cf9354d9ba445f306123cd229f1806d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 13 Sep 2026 00:24:36 +0100 Subject: [PATCH] feat(maestro): add Android daemon connection state machine Ports the DaemonLink state machine from web/src/components/daemon/DaemonLink.tsx and crates/maestro-tui/src/daemon_link.rs to Android: LinkState (Connecting, Online, Reconnecting, Down, Updating), build-stamp identity comparison, a session catalog folded from the events WebSocket, and exponential backoff (500ms-10s) in place of the flat poll the LAN clients use, since a phone's network changes are bigger and longer-lived than a wifi blip. DaemonLink reacts to ProcessLifecycleOwner (foreground resume) and ConnectivityManager (network change) to re-probe and reconnect. DaemonPrefs persists the daemon base URL via SharedPreferences and exposes an intent extra key for overriding it at launch. --- android/app/build.gradle.kts | 1 + .../pt/naps/maestro/connection/Backoff.kt | 32 ++ .../naps/maestro/connection/CatalogStore.kt | 49 +++ .../connection/ConnectivityObserver.kt | 51 ++++ .../naps/maestro/connection/DaemonIdentity.kt | 37 +++ .../pt/naps/maestro/connection/DaemonLink.kt | 287 ++++++++++++++++++ .../pt/naps/maestro/connection/DaemonPrefs.kt | 40 +++ .../pt/naps/maestro/connection/LinkState.kt | 14 + .../pt/naps/maestro/connection/BackoffTest.kt | 21 ++ .../maestro/connection/CatalogStoreTest.kt | 60 ++++ .../maestro/connection/DaemonIdentityTest.kt | 55 ++++ android/gradle/libs.versions.toml | 1 + 12 files changed, 648 insertions(+) create mode 100644 android/app/src/main/java/pt/naps/maestro/connection/Backoff.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/connection/CatalogStore.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/connection/ConnectivityObserver.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/connection/DaemonIdentity.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/connection/DaemonLink.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/connection/DaemonPrefs.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/connection/LinkState.kt create mode 100644 android/app/src/test/java/pt/naps/maestro/connection/BackoffTest.kt create mode 100644 android/app/src/test/java/pt/naps/maestro/connection/CatalogStoreTest.kt create mode 100644 android/app/src/test/java/pt/naps/maestro/connection/DaemonIdentityTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1b65d77..50e46ad 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -58,6 +58,7 @@ android { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) diff --git a/android/app/src/main/java/pt/naps/maestro/connection/Backoff.kt b/android/app/src/main/java/pt/naps/maestro/connection/Backoff.kt new file mode 100644 index 0000000..b876621 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/connection/Backoff.kt @@ -0,0 +1,32 @@ +package pt.naps.maestro.connection + +/** + * Exponential backoff for daemon reconnect attempts: doubles from + * [initialMs] up to [maxMs], then holds. A phone's radio swinging between + * wifi and cellular (or losing signal outright) is a bigger, longer-lived + * change than the LAN blips the web/TUI clients ride out with a flat poll, + * so this backs off instead of hammering the daemon while nothing changed. + */ +class Backoff( + private val initialMs: Long = INITIAL_MS, + private val maxMs: Long = MAX_MS, +) { + private var currentMs = initialMs + + /** Delay before the next attempt; advances the sequence for the one after. */ + fun nextDelayMs(): Long { + val delay = currentMs + currentMs = (currentMs * 2).coerceAtMost(maxMs) + return delay + } + + /** Back to [initialMs] after a successful connection. */ + fun reset() { + currentMs = initialMs + } + + companion object { + const val INITIAL_MS = 500L + const val MAX_MS = 10_000L + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/connection/CatalogStore.kt b/android/app/src/main/java/pt/naps/maestro/connection/CatalogStore.kt new file mode 100644 index 0000000..4c4ac78 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/connection/CatalogStore.kt @@ -0,0 +1,49 @@ +package pt.naps.maestro.connection + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import pt.naps.maestro.data.CatalogEvent +import pt.naps.maestro.data.Session +import pt.naps.maestro.data.Uuid + +/** + * Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot] + * replaces the map outright; deltas mutate one entry. + */ +class CatalogStore { + private val _sessions = MutableStateFlow>(emptyMap()) + val sessions: StateFlow> = _sessions.asStateFlow() + + /** Applies one event. Returns the skip count for [CatalogEvent.Lagged] — + * the caller re-fetches a full snapshot over REST and calls [replace], + * mirroring how the web client reacts to a dropped broadcast frame. */ + fun apply(event: CatalogEvent): Long? = + when (event) { + is CatalogEvent.Snapshot -> { + _sessions.value = event.sessions.associateBy { it.id } + null + } + is CatalogEvent.SessionAdded -> { + _sessions.value = _sessions.value + (event.session.id to event.session) + null + } + is CatalogEvent.SessionUpdated -> { + _sessions.value = _sessions.value + (event.session.id to event.session) + null + } + is CatalogEvent.SessionRemoved -> { + _sessions.value = _sessions.value - event.id + null + } + is CatalogEvent.Lagged -> event.skipped + } + + fun replace(sessions: List) { + _sessions.value = sessions.associateBy { it.id } + } + + fun clear() { + _sessions.value = emptyMap() + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/connection/ConnectivityObserver.kt b/android/app/src/main/java/pt/naps/maestro/connection/ConnectivityObserver.kt new file mode 100644 index 0000000..2f08811 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/connection/ConnectivityObserver.kt @@ -0,0 +1,51 @@ +package pt.naps.maestro.connection + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest + +/** + * Notifies [onAvailable] whenever a usable network appears — including the + * wifi/cellular handoff, which Android reports as one network disappearing + * and another becoming available rather than a single "changed" event. + * [DaemonLink] treats every call as "worth reconnecting for" rather than + * inspecting which network it is. + */ +class ConnectivityObserver( + context: Context, + private val onAvailable: () -> Unit, +) { + private val connectivityManager = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = onAvailable.invoke() + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ) { + onAvailable.invoke() + } + } + } + + private var registered = false + + fun start() { + if (registered) return + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + registered = true + } + + fun stop() { + if (!registered) return + runCatching { connectivityManager.unregisterNetworkCallback(callback) } + registered = false + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/connection/DaemonIdentity.kt b/android/app/src/main/java/pt/naps/maestro/connection/DaemonIdentity.kt new file mode 100644 index 0000000..674fa94 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/connection/DaemonIdentity.kt @@ -0,0 +1,37 @@ +package pt.naps.maestro.connection + +import pt.naps.maestro.data.BuildInfo +import pt.naps.maestro.data.DaemonInfo + +/** What happened between two `/api/info` replies. */ +enum class RestartKind { SAME, RESTARTED, UPGRADED } + +/** + * Compare two daemon replies. Mirrors `compareIdentity` in + * `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in + * `crates/maestro-tui/src/daemon_link.rs`. + * + * The commit decides first: a different one is new server code. `instance` + * catches everything else — a restart of the same build — and is the only + * signal for a daemon built outside a git checkout, where the commit is + * always empty and an empty-vs-empty pair must never read as an upgrade. + */ +fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind { + val a = before.build.commit + val b = after.build.commit + if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED + if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) { + return RestartKind.RESTARTED + } + return RestartKind.SAME +} + +/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the + * build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */ +fun formatRelease(build: BuildInfo): String { + if (build.commit.isEmpty()) return "unknown release" + val parts = mutableListOf(build.commit) + if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}") + if (build.dirty) parts.add("(dirty)") + return parts.joinToString(" ") +} diff --git a/android/app/src/main/java/pt/naps/maestro/connection/DaemonLink.kt b/android/app/src/main/java/pt/naps/maestro/connection/DaemonLink.kt new file mode 100644 index 0000000..1f134e2 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/connection/DaemonLink.kt @@ -0,0 +1,287 @@ +package pt.naps.maestro.connection + +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import pt.naps.maestro.api.EventsConnection +import pt.naps.maestro.api.MaestroClient +import pt.naps.maestro.api.MaestroWebSocket +import pt.naps.maestro.data.BuildInfo +import pt.naps.maestro.data.DaemonInfo + +/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to + * [LinkState.DOWN] — long enough to ride out a blip, short enough that a real + * outage shows up. Mirrors the grace window in `docs/RESTART.md`. */ +private const val GRACE_MS = 1_200L + +/** + * Owns the connection to one daemon for the app's whole life: liveness, + * identity, and the session catalog. A Kotlin port of the state machine in + * `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs` + * — see `docs/RESTART.md` for the contract both mirror — with exponential + * [Backoff] standing in for their flat poll, since a phone's network changes + * are bigger and longer-lived than the LAN blips those clients ride out. + * + * Everything that can change the link — a probe landing, the events socket + * dropping, the app resuming, the network coming back, a new daemon URL — + * goes through one [signals] channel consumed by one coroutine, so state + * transitions never race each other. + */ +class DaemonLink( + context: Context, + private val prefs: DaemonPrefs, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val httpClient: OkHttpClient = OkHttpClient(), +) : DefaultLifecycleObserver { + private val appContext = context.applicationContext + + private val _state = MutableStateFlow(LinkState.CONNECTING) + val state: StateFlow = _state.asStateFlow() + + private val _build = MutableStateFlow(null) + val build: StateFlow = _build.asStateFlow() + + private val _attempts = MutableStateFlow(0) + val attempts: StateFlow = _attempts.asStateFlow() + + val catalog = CatalogStore() + + private val backoff = Backoff() + private val signals = Channel(Channel.UNLIMITED) + private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) } + + private var generation = 0 + private var currentBaseUrl: String = prefs.baseUrl.value + private var client = MaestroClient(currentBaseUrl, httpClient) + private var socket = MaestroWebSocket(currentBaseUrl, httpClient) + private var identity: DaemonInfo? = null + + private var eventsJob: Job? = null + private var eventsConnection: EventsConnection? = null + private var retryJob: Job? = null + private var graceJob: Job? = null + private var loopJob: Job? = null + private var started = false + + private sealed class Signal { + object Start : Signal() + object Resume : Signal() + object NetworkAvailable : Signal() + object SocketDropped : Signal() + data class BaseUrlChanged(val url: String) : Signal() + data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal() + data class Lagged(val skipped: Long) : Signal() + } + + /** Starts the link: subscribes to process lifecycle and connectivity, and + * kicks off the first probe. Call once. */ + fun start() { + if (started) return + started = true + loopJob = scope.launch { runLoop() } + connectivity.start() + scope.launch(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink) + } + scope.launch { + prefs.baseUrl.collect { url -> + if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url)) + } + } + signals.trySend(Signal.Start) + } + + /** Tears everything down. Only needed by tests and by an owner that is + * itself going away for good (this link is meant to outlive an Activity). */ + fun close() { + connectivity.stop() + cancelRetry() + cancelGrace() + closeEvents() + loopJob?.cancel() + signals.close() + scope.launch(Dispatchers.Main.immediate) { + runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) } + } + } + + override fun onStart(owner: LifecycleOwner) { + signals.trySend(Signal.Resume) + } + + private suspend fun runLoop() { + for (signal in signals) { + when (signal) { + is Signal.Start -> probeNow() + is Signal.Resume -> { + probeNow() + ensureEventsConnected() + } + is Signal.NetworkAvailable -> { + cancelRetry() + probeNow() + } + is Signal.SocketDropped -> handleSocketDropped() + is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url) + is Signal.ProbeResult -> { + if (signal.generation == generation) handleProbeResult(signal.info) + } + is Signal.Lagged -> handleLagged() + } + } + } + + private fun probeNow() { + val gen = generation + val currentClient = client + scope.launch { + val info = runCatching { currentClient.getDaemonInfo() }.getOrNull() + signals.trySend(Signal.ProbeResult(gen, info)) + } + } + + private fun handleProbeResult(info: DaemonInfo?) { + if (info == null) { + onProbeFailed() + return + } + val before = identity + identity = info + _build.value = info.build + if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) { + _state.value = LinkState.UPDATING + fullReconnect() + return + } + backoff.reset() + cancelRetry() + cancelGrace() + _attempts.value = 0 + _state.value = LinkState.ONLINE + ensureEventsConnected() + } + + private fun onProbeFailed() { + _attempts.value += 1 + if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) { + _state.value = LinkState.RECONNECTING + armGrace() + } + scheduleRetry() + } + + private fun armGrace() { + if (graceJob?.isActive == true) return + graceJob = scope.launch { + delay(GRACE_MS) + if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN + } + } + + private fun cancelGrace() { + graceJob?.cancel() + graceJob = null + } + + private fun scheduleRetry() { + if (retryJob?.isActive == true) return + val delayMs = backoff.nextDelayMs() + retryJob = scope.launch { + delay(delayMs) + retryJob = null + probeNow() + } + } + + private fun cancelRetry() { + retryJob?.cancel() + retryJob = null + backoff.reset() + } + + /** A build-stamp change: drop the events socket and the catalog it fed, + * then probe again shortly. The re-probe sees the same identity it just + * recorded, so it resolves straight to [LinkState.ONLINE] and reopens + * the socket — this *is* the full reconnect. */ + private fun fullReconnect() { + closeEvents() + catalog.clear() + backoff.reset() + scope.launch { + delay(200) + probeNow() + } + } + + private fun handleSocketDropped() { + if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return + _state.value = LinkState.RECONNECTING + armGrace() + probeNow() + } + + private fun handleBaseUrlChanged(url: String) { + currentBaseUrl = url + generation += 1 + client = MaestroClient(url, httpClient) + socket = MaestroWebSocket(url, httpClient) + identity = null + _build.value = null + _attempts.value = 0 + backoff.reset() + cancelRetry() + cancelGrace() + closeEvents() + catalog.clear() + _state.value = LinkState.CONNECTING + probeNow() + } + + /** The broadcast channel dropped frames: re-fetch the full list over REST + * rather than trusting the map to still be complete. */ + private fun handleLagged() { + val currentClient = client + scope.launch { + runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) } + } + } + + private fun ensureEventsConnected() { + if (eventsJob?.isActive == true) return + val connection = socket.connectEvents() + eventsConnection = connection + eventsJob = scope.launch { + try { + connection.events.collect { event -> + val skipped = catalog.apply(event) + if (skipped != null) signals.trySend(Signal.Lagged(skipped)) + } + signals.trySend(Signal.SocketDropped) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + signals.trySend(Signal.SocketDropped) + } + } + } + + private fun closeEvents() { + eventsJob?.cancel() + eventsJob = null + eventsConnection?.close() + eventsConnection = null + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/connection/DaemonPrefs.kt b/android/app/src/main/java/pt/naps/maestro/connection/DaemonPrefs.kt new file mode 100644 index 0000000..4a39d47 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/connection/DaemonPrefs.kt @@ -0,0 +1,40 @@ +package pt.naps.maestro.connection + +import android.content.Context +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Where the daemon base URL lives between launches. A settings screen and + * the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink] + * reads [baseUrl] and reconnects on change. + */ +class DaemonPrefs(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) + + private val _baseUrl = + MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL) + val baseUrl: StateFlow = _baseUrl.asStateFlow() + + fun setBaseUrl(url: String) { + val trimmed = url.trim() + if (trimmed.isEmpty() || trimmed == _baseUrl.value) return + prefs.edit { putString(KEY_BASE_URL, trimmed) } + _baseUrl.value = trimmed + } + + companion object { + private const val FILE = "daemon_prefs" + private const val KEY_BASE_URL = "base_url" + + /** Shown until the user points the app at their own daemon. */ + const val DEFAULT_BASE_URL = "http://192.168.1.x:7070" + + /** Intent extra a launcher may set to point this launch at a specific + * daemon without going through settings. Persisted on read, so it + * sticks after the launch that carried it. */ + const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL" + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/connection/LinkState.kt b/android/app/src/main/java/pt/naps/maestro/connection/LinkState.kt new file mode 100644 index 0000000..b514608 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/connection/LinkState.kt @@ -0,0 +1,14 @@ +package pt.naps.maestro.connection + +/** + * Connection lifecycle to the daemon. Mirrors the states in + * `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs` + * (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change. + * + * - [CONNECTING] — first probe hasn't landed yet. + * - [ONLINE] — the daemon answered and identity is settled. + * - [RECONNECTING] — the link dropped, still inside the grace window. + * - [DOWN] — unreachable past the grace window; the restart overlay blocks. + * - [UPDATING] — the daemon answered on a different commit; reconnecting fresh. + */ +enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING } diff --git a/android/app/src/test/java/pt/naps/maestro/connection/BackoffTest.kt b/android/app/src/test/java/pt/naps/maestro/connection/BackoffTest.kt new file mode 100644 index 0000000..1fe2190 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/connection/BackoffTest.kt @@ -0,0 +1,21 @@ +package pt.naps.maestro.connection + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BackoffTest { + @Test + fun `doubles from 500ms and caps at 10s`() { + val backoff = Backoff(initialMs = 500L, maxMs = 10_000L) + val delays = List(8) { backoff.nextDelayMs() } + assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays) + } + + @Test + fun `reset returns to the initial delay`() { + val backoff = Backoff(initialMs = 500L, maxMs = 10_000L) + repeat(4) { backoff.nextDelayMs() } + backoff.reset() + assertEquals(500L, backoff.nextDelayMs()) + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/connection/CatalogStoreTest.kt b/android/app/src/test/java/pt/naps/maestro/connection/CatalogStoreTest.kt new file mode 100644 index 0000000..ea05538 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/connection/CatalogStoreTest.kt @@ -0,0 +1,60 @@ +package pt.naps.maestro.connection + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import pt.naps.maestro.data.CatalogEvent +import pt.naps.maestro.data.Session +import pt.naps.maestro.data.SessionStatus + +class CatalogStoreTest { + private fun session(id: String) = Session( + id = id, + cwd = "/tmp", + command = "bash", + status = SessionStatus.Running, + created_at = "2026-05-24T12:00:00Z", + ) + + @Test + fun `snapshot replaces the map keyed by id`() { + val store = CatalogStore() + store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b")))) + assertEquals(setOf("a", "b"), store.sessions.value.keys) + } + + @Test + fun `added and updated upsert one entry`() { + val store = CatalogStore() + store.apply(CatalogEvent.Snapshot(emptyList())) + store.apply(CatalogEvent.SessionAdded(session("a"))) + assertEquals(1, store.sessions.value.size) + + val renamed = session("a").copy(command = "zsh") + store.apply(CatalogEvent.SessionUpdated(renamed)) + assertEquals("zsh", store.sessions.value.getValue("a").command) + } + + @Test + fun `removed drops the entry`() { + val store = CatalogStore() + store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b")))) + store.apply(CatalogEvent.SessionRemoved("a")) + assertEquals(setOf("b"), store.sessions.value.keys) + } + + @Test + fun `lagged reports the skip count without touching the map`() { + val store = CatalogStore() + store.apply(CatalogEvent.Snapshot(listOf(session("a")))) + val skipped = store.apply(CatalogEvent.Lagged(3)) + assertEquals(3L, skipped) + assertEquals(setOf("a"), store.sessions.value.keys) + } + + @Test + fun `non-lagged events report no skip`() { + val store = CatalogStore() + assertNull(store.apply(CatalogEvent.Snapshot(emptyList()))) + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/connection/DaemonIdentityTest.kt b/android/app/src/test/java/pt/naps/maestro/connection/DaemonIdentityTest.kt new file mode 100644 index 0000000..af83eaf --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/connection/DaemonIdentityTest.kt @@ -0,0 +1,55 @@ +package pt.naps.maestro.connection + +import org.junit.Assert.assertEquals +import org.junit.Test +import pt.naps.maestro.data.BuildInfo +import pt.naps.maestro.data.DaemonInfo + +class DaemonIdentityTest { + private fun info(commit: String = "", instance: String = "") = + DaemonInfo(build = BuildInfo(commit = commit), instance = instance) + + @Test + fun `same commit and instance is Same`() { + val a = info(commit = "abc123", instance = "i1") + assertEquals(RestartKind.SAME, compareIdentity(a, a.copy())) + } + + @Test + fun `different commit is Upgraded`() { + val before = info(commit = "abc123", instance = "i1") + val after = info(commit = "def456", instance = "i2") + assertEquals(RestartKind.UPGRADED, compareIdentity(before, after)) + } + + @Test + fun `same commit but different instance is Restarted`() { + val before = info(commit = "abc123", instance = "i1") + val after = info(commit = "abc123", instance = "i2") + assertEquals(RestartKind.RESTARTED, compareIdentity(before, after)) + } + + @Test + fun `empty commits on both sides never read as an upgrade`() { + val before = info(commit = "", instance = "i1") + val after = info(commit = "", instance = "i2") + assertEquals(RestartKind.RESTARTED, compareIdentity(before, after)) + } + + @Test + fun `format release names the commit and short date`() { + val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false) + assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build)) + } + + @Test + fun `dirty build is flagged`() { + val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true) + assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build)) + } + + @Test + fun `empty commit is unknown release`() { + assertEquals("unknown release", formatRelease(BuildInfo())) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 881e243..338a951 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -13,6 +13,7 @@ kotlinxCoroutines = "1.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" }