fix(android): animate visible idle sphere efficiently
This commit is contained in:
@@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
|
||||
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
|
||||
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -25,6 +26,8 @@ import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* ASCII morphing sphere — the visual embodiment of the AI agent.
|
||||
@@ -53,6 +56,32 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
|
||||
private const val SPHERE_TWO_PI = 6.2832f
|
||||
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
|
||||
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
|
||||
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
|
||||
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
|
||||
|
||||
internal enum class SphereMotionMode {
|
||||
Still,
|
||||
AmbientLayer,
|
||||
Procedural,
|
||||
}
|
||||
|
||||
internal fun sphereMotionMode(
|
||||
state: SphereState,
|
||||
voiceMode: Boolean,
|
||||
motionVisible: Boolean,
|
||||
fixedTime: Float?,
|
||||
fixedColorPhase: Float?,
|
||||
): SphereMotionMode {
|
||||
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
|
||||
return SphereMotionMode.Still
|
||||
}
|
||||
return if (state == SphereState.Idle && !voiceMode) {
|
||||
SphereMotionMode.AmbientLayer
|
||||
} else {
|
||||
SphereMotionMode.Procedural
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
@@ -64,7 +93,8 @@ fun MorphingSphere(
|
||||
voiceMode: Boolean = false,
|
||||
skin: SphereSkin = LocalSphereSkin.current,
|
||||
fixedTime: Float? = null,
|
||||
fixedColorPhase: Float? = null
|
||||
fixedColorPhase: Float? = null,
|
||||
motionVisible: Boolean = true,
|
||||
) {
|
||||
val brand = LocalBrand.current
|
||||
// Gate reactive inputs on what the skin declares it honors — this is the
|
||||
@@ -104,16 +134,21 @@ fun MorphingSphere(
|
||||
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
|
||||
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
|
||||
|
||||
// Continuous motion runs only for active agent/voice states. Idle is a
|
||||
// stable frame: the 58x34 text grid is expensive enough that even a
|
||||
// throttled cosmetic drift dominated measured screen-on CPU. Active states
|
||||
// retain full display-rate motion and dt-based timing.
|
||||
// Active states retain the full procedural animation. Visible Idle uses a
|
||||
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
|
||||
// for ambient drift was the measured screen-on hotspot, while transforming
|
||||
// its cached layer preserves the intended living Sphere at far lower cost.
|
||||
val animatedTime = remember { mutableFloatStateOf(0f) }
|
||||
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
|
||||
val fullFrameRate = state != SphereState.Idle || effVoiceMode
|
||||
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
|
||||
if (driveAnimation) {
|
||||
LaunchedEffect(fullFrameRate) {
|
||||
val motionMode = sphereMotionMode(
|
||||
state = state,
|
||||
voiceMode = effVoiceMode,
|
||||
motionVisible = motionVisible,
|
||||
fixedTime = fixedTime,
|
||||
fixedColorPhase = fixedColorPhase,
|
||||
)
|
||||
if (motionMode == SphereMotionMode.Procedural) {
|
||||
LaunchedEffect(motionMode) {
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
@@ -127,6 +162,25 @@ fun MorphingSphere(
|
||||
}
|
||||
}
|
||||
}
|
||||
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(motionMode) {
|
||||
if (motionMode != SphereMotionMode.AmbientLayer) {
|
||||
idleBreathPhase.floatValue = 0f
|
||||
return@LaunchedEffect
|
||||
}
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
|
||||
lastNanos = now
|
||||
idleBreathPhase.floatValue =
|
||||
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
|
||||
SPHERE_TWO_PI
|
||||
// The frame wait plus this delay caps the gentle layer-only pulse
|
||||
// near 5fps while active procedural states retain display-rate motion.
|
||||
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val time = fixedTime ?: animatedTime.floatValue
|
||||
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
|
||||
@@ -138,7 +192,18 @@ fun MorphingSphere(
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
|
||||
val glyphStrings = remember { HashMap<Char, String>(32) }
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
if (motionMode == SphereMotionMode.AmbientLayer) {
|
||||
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
}
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val canvasW = size.width
|
||||
val canvasH = size.height
|
||||
val cellW = canvasW / cols
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hermesandroid.relay.ui.components.avatar
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.SphereReactivity
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
|
||||
/**
|
||||
* Default ambient visualization — the ASCII [MorphingSphere].
|
||||
@@ -34,6 +37,7 @@ object SphereAvatar : AgentAvatar {
|
||||
|
||||
@Composable
|
||||
override fun Render(state: AvatarRenderState, modifier: Modifier) {
|
||||
val appForeground by AppForegroundTracker.isForeground.collectAsState()
|
||||
MorphingSphere(
|
||||
modifier = modifier,
|
||||
state = state.state,
|
||||
@@ -46,6 +50,7 @@ object SphereAvatar : AgentAvatar {
|
||||
// call did with fixedTime/fixedColorPhase = 0f.
|
||||
fixedTime = if (state.paused) 0f else null,
|
||||
fixedColorPhase = if (state.paused) 0f else null,
|
||||
motionVisible = appForeground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MorphingSphereMotionPolicyTest {
|
||||
@Test
|
||||
fun `visible idle sphere uses lightweight ambient motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.AmbientLayer,
|
||||
sphereMotionMode(
|
||||
state = SphereState.Idle,
|
||||
voiceMode = false,
|
||||
motionVisible = true,
|
||||
fixedTime = null,
|
||||
fixedColorPhase = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden or paused idle sphere is still`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, false, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible active and voice states keep procedural motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Thinking, false, true, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Idle, true, true, null, null),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,25 @@ Rule of thumb: where a surface is CI-gateable, write the **failing test first**
|
||||
is the deliberate exception — CI only covers lint + unit there, so on-device
|
||||
verification stays a manual maintainer step and a fix is never "done" from CI alone.
|
||||
|
||||
### Emulator UI evidence
|
||||
|
||||
Hermes Android is dark-mode-first. Before emulator screenshots, animation
|
||||
review, or renderer performance measurements, explicitly enable Android dark
|
||||
mode and restart the app so evidence is not captured in the emulator's light
|
||||
default:
|
||||
|
||||
```bash
|
||||
adb -s <emulator-serial> shell cmd uimode night yes
|
||||
adb -s <emulator-serial> shell am force-stop com.axiomlabs.hermesrelay.sideload
|
||||
adb -s <emulator-serial> shell am start -n \
|
||||
com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
|
||||
adb -s <emulator-serial> shell cmd uimode night
|
||||
```
|
||||
|
||||
Confirm the final command reports `Night mode: yes` before capturing evidence.
|
||||
Use host GPU acceleration where available; software rendering is useful for
|
||||
compatibility but is not representative performance evidence.
|
||||
|
||||
## Local bridge: `scripts/start-issue.sh`
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user