Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,18 @@ import android.view.WindowManager
* touchable and not focusable, so the gestures the control tools dispatch pass
* straight through to the app underneath.
*
* Lifecycle is transient: each observe/control call [signal]s the overlay, which
* shows (or refreshes) the animated frame and schedules a debounced hide once the
* agent goes quiet. Nothing is drawn — and no GPU work happens — while idle.
* Lifecycle: each observe/control call [signal]s the overlay, which attaches the
* window (if needed) and [pokes][LiquidMetalBorderView.poke] the frame so it animates.
* The frame quiesces on its own shortly after the calls stop — the view owns that
* timing — and nothing is drawn (no GPU work) while idle. The window itself stays
* attached longer ([KEEP_AWAKE_MILLIS]) purely to hold the screen awake between
* commands: [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] keeps the display from
* dimming or sleeping for as long as this window is attached, with no WAKE_LOCK
* permission and no device-wide timeout change. The window is removed once no command
* has arrived for the full keep-awake window, releasing the screen back to the system
* timeout. FLAG_KEEP_SCREEN_ON only prevents an already-on screen from sleeping; it
* cannot wake a screen that is already off, which is consistent with
* [AndroidComputerSource] refusing to act while the screen is off.
*
* The border DOES appear in accessibility screenshots (it is composited onto the
* display like any window). It is kept thin so it only touches the extreme screen
Expand All @@ -32,7 +41,7 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) {
private val windowManager = service.getSystemService(WindowManager::class.java)
private var view: LiquidMetalBorderView? = null

private val hideRunnable = Runnable { removeView() }
private val removeRunnable = Runnable { removeView() }

/**
* Mark computer-use activity of [mode]. Safe to call from any thread; the work
Expand All @@ -41,17 +50,16 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) {
fun signal(mode: Mode) {
main.post {
ensureAttached()
view?.setMode(mode)
view?.startAnimating()
main.removeCallbacks(hideRunnable)
main.postDelayed(hideRunnable, LINGER_MILLIS)
view?.poke(mode)
main.removeCallbacks(removeRunnable)
main.postDelayed(removeRunnable, KEEP_AWAKE_MILLIS)
}
}

/** Tear down the overlay for good. Call when the service unbinds or is destroyed. */
fun destroy() {
main.post {
main.removeCallbacks(hideRunnable)
main.removeCallbacks(removeRunnable)
removeView()
}
}
Expand All @@ -68,7 +76,8 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) {
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
PixelFormat.TRANSLUCENT,
)
if (runCatching { manager.addView(overlay, params) }.isSuccess) {
Expand All @@ -84,7 +93,15 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) {
}

private companion object {
/** How long the frame lingers after the last observe/control call. */
const val LINGER_MILLIS = 1_500L
/**
* How long the (blank, non-drawing) window stays attached after the last
* command, holding the screen awake between actions via FLAG_KEEP_SCREEN_ON.
* This is an idle timeout, not a session length: computer use has no explicit
* end signal (it is a stream of discrete observe/control calls), so the window
* is released once no command has arrived for this long. A gap LONGER than this
* between two consecutive actions will let the screen dim/sleep mid-session; 60s
* comfortably covers normal per-action latency (model + network + UI settle).
*/
const val KEEP_AWAKE_MILLIS = 60_000L
}
}
31 changes: 29 additions & 2 deletions app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class LiquidMetalBorderView(context: Context) : View(context) {

private var mode: ComputerUseBorderOverlay.Mode = ComputerUseBorderOverlay.Mode.OBSERVE
private var startNanos = 0L
private var animateUntilNanos = 0L
private var animating = false

private val strokeRect = RectF()
Expand All @@ -57,6 +58,12 @@ class LiquidMetalBorderView(context: Context) : View(context) {

private val frameCallback: Choreographer.FrameCallback = Choreographer.FrameCallback {
if (!animating) return@FrameCallback
if (System.nanoTime() >= animateUntilNanos) {
// Calls stopped arriving; quiesce and clear the frame (the window may stay
// attached to hold the screen awake, but nothing is drawn while idle).
stopAnimating()
return@FrameCallback
}
invalidate()
Choreographer.getInstance().postFrameCallback(frameCallback)
}
Expand All @@ -68,22 +75,36 @@ class LiquidMetalBorderView(context: Context) : View(context) {
isFocusable = false
}

fun setMode(mode: ComputerUseBorderOverlay.Mode) {
private fun setMode(mode: ComputerUseBorderOverlay.Mode) {
if (this.mode == mode) return
this.mode = mode
sweepShader = null
}

fun startAnimating() {
/**
* Show/refresh the frame in [mode] and keep it animating for at least
* [FRAME_LINGER_MILLIS] longer. Safe to call repeatedly: the frame animates
* continuously while calls keep arriving and quiesces on its own (clearing to
* transparent) once they stop. The animation time base ([startNanos]) is set only
* when starting from idle, so repeated pokes extend the frame without a visual
* jump.
*/
fun poke(mode: ComputerUseBorderOverlay.Mode) {
setMode(mode)
animateUntilNanos = System.nanoTime() + FRAME_LINGER_MILLIS * 1_000_000L
if (animating) return
animating = true
startNanos = System.nanoTime()
Choreographer.getInstance().postFrameCallback(frameCallback)
}

fun stopAnimating() {
if (!animating) return
animating = false
Choreographer.getInstance().removeFrameCallback(frameCallback)
// Repaint once so the now-idle frame clears. The window itself stays attached
// (holding the screen awake); onDraw draws nothing while not animating.
invalidate()
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
Expand All @@ -97,6 +118,9 @@ class LiquidMetalBorderView(context: Context) : View(context) {
}

override fun onDraw(canvas: Canvas) {
// Idle: draw nothing. The window may stay attached to keep the screen awake,
// but the frame is only painted while actively signalling.
if (!animating) return
val inset = borderWidthPx / 2f
strokeRect.set(inset, inset, width - inset, height - inset)
val timeSeconds = (System.nanoTime() - startNanos) / 1_000_000_000f
Expand Down Expand Up @@ -153,6 +177,9 @@ class LiquidMetalBorderView(context: Context) : View(context) {
private data class Palette(val a: Int, val b: Int, val c: Int, val d: Int, val e: Int)

private companion object {
/** How long the frame keeps animating after the last [poke]. */
const val FRAME_LINGER_MILLIS = 1_500L

const val BORDER_WIDTH_DP = 6f
const val CORNER_RADIUS_DP = 28f
const val SWEEP_DEGREES_PER_SEC = 60f
Expand Down
Loading