Compare commits

...
Author SHA1 Message Date
Bailey Dixon 16233a712d release(android): android-v1.13.2 2026-08-25 19:45:39 -04:00
Bailey Dixon 0e8b44080d fix(android): keep unknown session activity neutral
(cherry picked from commit ae9b22a9e6)
2026-08-25 19:44:28 -04:00
14 changed files with 91 additions and 47 deletions
+6
View File
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [Android 1.13.2] - 2026-08-25
### Fixed
- **Android session rows stay neutral when optional live activity is unavailable or still loading.** Directory refreshes no longer restore a persistent Checking state, and full-row activity borders are reserved for actual Starting or Working turns.
## [Android 1.13.1] - 2026-08-25
### Fixed
+6 -7
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.13.1
# Hermes-Relay Android v1.13.2
**Release Date:** August 25, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,17 +12,16 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
This patch keeps session rows neutral while optional live activity is unavailable or still loading. Full-row activity borders now appear only for actual Starting or Working turns.
## Fixed
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
- Stop unavailable or in-flight activity checks from presenting a persistent Checking state.
- Reserve full-row activity borders for actual Starting and Working turns.
## Install / Verify
- App version: **1.13.1** (versionCode **50**).
- App version: **1.13.2** (versionCode **51**).
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
@@ -1 +1 @@
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
Session rows now stay neutral while optional live activity is unavailable or still loading. Full-row activity borders appear only for actual Starting or Working turns.
@@ -1 +1 @@
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
当可选的实时活动状态不可用或仍在加载时,会话行现在保持中性显示。只有会话真正处于“启动中”或“工作中”时,才会显示整行活动边框。
+14
View File
@@ -1,5 +1,19 @@
{
"versions": [
{
"version": "1.13.2",
"title": "Clear session activity",
"date": "2026-08-25",
"sections": [
{
"header": "Show only confirmed activity",
"bullets": [
"Keep session rows neutral while optional live activity is unavailable or still loading.",
"Show full-row activity borders only during actual Starting or Working turns."
]
}
]
},
{
"version": "1.13.1",
"title": "Accurate session activity",
+3 -4
View File
@@ -1,5 +1,4 @@
v1.13.1 - Accurate session activity
v1.13.2 - Clear session activity
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
* Distinguish Checking, Unavailable, and Background work in the session drawer.
* Keep session rows neutral while optional live activity is unavailable or loading.
* Show full-row activity borders only during actual Starting or Working turns.
@@ -90,10 +90,10 @@ data class SessionActivityRecord(
}
}
/** Presentation projection that never labels uncertain or background activity as Working. */
/** Presentation projection that never labels missing optional runtime data as session state. */
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
SessionActivityFreshness.Revalidating -> null
SessionActivityFreshness.Unavailable -> null
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
SessionActivityPhase.Starting -> SessionActivityState.Starting
SessionActivityPhase.Working -> SessionActivityState.Working
@@ -294,7 +294,9 @@ data class SessionActivityRegistry(
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
val existing = records[update.owner]
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
// Directory rows establish ownership only. They are not live evidence and must not
// turn an unsupported/failed active-list probe back into a permanent Checking row.
if (existing != null) return this
val observed = SessionActivityRecord(
owner = update.owner,
turnPhase = SessionActivityPhase.Idle,
@@ -1752,19 +1752,9 @@ private fun Modifier.sessionActivityBorder(
state: SessionActivityState?,
animated: Boolean,
): Modifier {
if (state == null) return this
val color = when (state) {
SessionActivityState.Starting,
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
SessionActivityState.BackgroundWork,
SessionActivityState.Checking,
SessionActivityState.Unavailable,
-> MaterialTheme.colorScheme.onSurfaceVariant
}
val shouldRotate = animated && (
state == SessionActivityState.Starting || state == SessionActivityState.Working
)
if (!sessionActivityShowsRowBorder(state)) return this
val color = RelayRefresh.Relay
val shouldRotate = animated
val phase = if (shouldRotate) {
val transition = rememberInfiniteTransition(label = "session-activity")
transition.animateFloat(
@@ -84,6 +84,10 @@ internal fun sessionDrawerStatus(
null -> SessionDrawerStatus.Idle
}
/** Desktop-style row emphasis is reserved for an actual foreground turn. */
internal fun sessionActivityShowsRowBorder(state: SessionActivityState?): Boolean =
state == SessionActivityState.Starting || state == SessionActivityState.Working
/**
* Normalizes live activity to the drawer's profile-scoped row identity.
*
@@ -10,17 +10,19 @@ class SessionActivityRegistryTest {
private val scope = SessionActivityScope.of("connection-a", "default")
@Test
fun `directory owner is checking until status is unavailable or confirms idle`() {
fun `directory owner stays neutral until status confirms live activity`() {
val checking = SessionActivityRegistry().reduce(
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
assertEquals(SessionActivityFreshness.Revalidating, checking.record(owner)?.freshness)
assertNull(checking.record(owner)?.presentationState())
val unavailable = checking.reduce(
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
)
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
assertEquals(SessionActivityFreshness.Unavailable, unavailable.record(owner)?.freshness)
assertNull(unavailable.record(owner)?.presentationState())
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
@@ -28,6 +30,17 @@ class SessionActivityRegistryTest {
assertNull(confirmedIdle.record(owner)?.presentationState())
}
@Test
fun `directory refresh cannot restore checking after active status is unavailable`() {
val state = SessionActivityRegistry()
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1))
.reduce(SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2))
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 3))
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `directory observation cannot downgrade confirmed live evidence`() {
val state = SessionActivityRegistry()
@@ -184,7 +197,7 @@ class SessionActivityRegistryTest {
}
@Test
fun `failed or unsupported status refresh is unavailable rather than idle`() {
fun `failed or unsupported status refresh preserves evidence but presents a neutral row`() {
val state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.LiveState(
@@ -206,7 +219,7 @@ class SessionActivityRegistryTest {
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
assertNull(state.record(owner)?.presentationState())
}
@Test
@@ -226,7 +239,7 @@ class SessionActivityRegistryTest {
}
@Test
fun `presentation keeps starting background and revalidation distinct from working`() {
fun `presentation keeps starting and background distinct while revalidation stays neutral`() {
val starting = SessionActivityRegistry().reduce(
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
)
@@ -240,7 +253,7 @@ class SessionActivityRegistryTest {
val checking = starting.reduce(
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
)
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
assertNull(checking.record(owner)?.presentationState())
}
@Test
@@ -287,7 +300,7 @@ class SessionActivityRegistryTest {
}
@Test
fun `restored needs-input checkpoint stays checking until live confirmation`() {
fun `restored needs-input checkpoint stays neutral until live confirmation`() {
val state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
@@ -298,7 +311,7 @@ class SessionActivityRegistryTest {
),
)
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
assertNull(state.record(owner)?.presentationState())
}
@Test
@@ -323,7 +336,7 @@ class SessionActivityRegistryTest {
),
)
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
assertNull(state.record(owner)?.presentationState())
state = state.reduce(
SessionActivityUpdate.PendingInputOpened(
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionDrawerPolicyTest {
@@ -144,6 +145,17 @@ class SessionDrawerPolicyTest {
}
}
@Test
fun `full row border is limited to foreground live work`() {
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Starting))
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Working))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.NeedsInput))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.BackgroundWork))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Checking))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Unavailable))
assertFalse(sessionActivityShowsRowBorder(null))
}
@Test
fun `profile project status and pull request filters compose`() {
val wanted = row(
@@ -333,7 +333,7 @@ class ChatViewModelGatewayInboundTurnTest {
}
@Test
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
fun unsupportedActiveListLeavesRowsNeutralAcrossDirectoryRefresh() {
bindActivityTestDirectory()
handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
@@ -344,9 +344,14 @@ class ChatViewModelGatewayInboundTurnTest {
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.Unavailable
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
}
viewModel.updateSessionActivityDirectory(
rows = listOf("default" to STORED_SESSION_ID),
)
assertFalse("default:$STORED_SESSION_ID" in viewModel.backgroundSessionActivityStates.value)
}
@Test
+2 -2
View File
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.13.1 - Accurate session activity
v1.13.2 - Clear session activity
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
Session rows now stay neutral while optional live activity is unavailable or still loading. Full-row activity borders appear only for actual Starting or Working turns.
```
## Category
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.13.1"
appVersionCode = "50"
appVersionName = "1.13.2"
appVersionCode = "51"
agp = "9.3.2"
kotlin = "2.4.10"
compose-bom = "2026.08.00"