From cfed2d287f045aaa05a61259efbea771bb828f54 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 04:42:34 +0000 Subject: [PATCH 1/2] Keep screen awake during active computer use The screen dimming or sleeping mid-session interrupts computer use: today AndroidComputerSource refuses to act once the screen is off, so a normal device screen timeout cuts sessions short. Add FLAG_KEEP_SCREEN_ON to the computer-use border overlay window, which is already re-armed on every observe/control call. This holds the display awake for as long as the window is attached, with no WAKE_LOCK permission and no device-wide timeout change, and releases on its own once the agent goes quiet. Decouple two lifetimes so behaviour stays clean: - The animated frame still hides ~1.5s after the last command (unchanged UX). - The window itself lingers ~60s to bridge the gaps between commands (model latency, network) so the screen does not dim between actions, then detaches. While idle the view paints nothing, so no GPU work happens; the window simply stays attached to hold the screen on. FLAG_KEEP_SCREEN_ON only keeps an already-on screen awake, consistent with the existing screen-off guard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01K3vMrbcQhiGuMZdUdg8hHg --- .../stdiod/mcp/ComputerUseBorderOverlay.kt | 52 +++++++++++++++---- .../stdiod/mcp/LiquidMetalBorderView.kt | 7 +++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt index 16b5ded..307b3e7 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt @@ -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 is transient and two-phased: each observe/control call [signal]s the + * overlay, which shows (or refreshes) the animated frame and re-arms two debounced + * timers. The frame itself is hidden shortly after the agent goes quiet + * ([FRAME_LINGER_MILLIS]); nothing is drawn — and no GPU work happens — while idle. + * The window stays attached longer ([KEEP_AWAKE_MILLIS]) purely to hold the screen + * awake between commands: the [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] flag + * 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 the agent has been quiet 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 @@ -32,7 +41,8 @@ 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 hideFrameRunnable = Runnable { hideFrame() } + private val removeRunnable = Runnable { removeView() } /** * Mark computer-use activity of [mode]. Safe to call from any thread; the work @@ -43,15 +53,18 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { ensureAttached() view?.setMode(mode) view?.startAnimating() - main.removeCallbacks(hideRunnable) - main.postDelayed(hideRunnable, LINGER_MILLIS) + main.removeCallbacks(hideFrameRunnable) + main.removeCallbacks(removeRunnable) + main.postDelayed(hideFrameRunnable, FRAME_LINGER_MILLIS) + 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(hideFrameRunnable) + main.removeCallbacks(removeRunnable) removeView() } } @@ -68,7 +81,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) { @@ -76,6 +90,16 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { } } + /** + * Stop drawing the frame but keep the window attached so its + * [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] still holds the screen awake + * through gaps between commands. The view stays attached and simply paints nothing + * while idle; [signal] re-arms the animation on the next command. + */ + private fun hideFrame() { + view?.stopAnimating() + } + private fun removeView() { val overlay = view ?: return overlay.stopAnimating() @@ -84,7 +108,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 animated frame lingers after the last observe/control call. */ + const val FRAME_LINGER_MILLIS = 1_500L + + /** + * How long the (blank, non-drawing) window stays attached after the last call + * to hold the screen awake between commands. Sized to comfortably cover pauses + * between agent actions (model latency, network) without pinning the screen + * on well past the end of a session. + */ + const val KEEP_AWAKE_MILLIS = 60_000L } } diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt index fc008b2..424c7b6 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt @@ -82,8 +82,12 @@ class LiquidMetalBorderView(context: Context) : View(context) { } 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) { @@ -97,6 +101,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 From b49aa526053f914dd9dd562e3940a2e4bba6795d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 04:53:55 +0000 Subject: [PATCH 2/2] Simplify: let the border view own its own linger Address code-review feedback on the keep-screen-on change. Fold the frame's linger timing into LiquidMetalBorderView so the overlay owns a single concern and a single timer: - The view self-limits: poke(mode) refreshes an animate-until deadline and the frame loop quiesces on its own once pokes stop, clearing to transparent. The animation time base is still set only when starting from idle, so repeated pokes extend the frame with no visual jump. - ComputerUseBorderOverlay drops the second timer, the hideFrame() pass-through, and its copy of the frame-linger constant. It now only schedules window removal (the keep-awake lifetime), and signal() reduces to attach + poke + reschedule. - setMode is now private (poke is the only entry point); startAnimating is gone. - Document KEEP_AWAKE_MILLIS as an idle timeout, not a session length, and state its failure mode: a gap longer than it between two actions lets the screen sleep mid-session. There is no explicit session-end signal to key off (computer use is a stream of discrete observe/control calls), so the idle timeout stands. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01K3vMrbcQhiGuMZdUdg8hHg --- .../stdiod/mcp/ComputerUseBorderOverlay.kt | 55 +++++++------------ .../stdiod/mcp/LiquidMetalBorderView.kt | 24 +++++++- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt index 307b3e7..98dd362 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt @@ -14,18 +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 and two-phased: each observe/control call [signal]s the - * overlay, which shows (or refreshes) the animated frame and re-arms two debounced - * timers. The frame itself is hidden shortly after the agent goes quiet - * ([FRAME_LINGER_MILLIS]); nothing is drawn — and no GPU work happens — while idle. - * The window stays attached longer ([KEEP_AWAKE_MILLIS]) purely to hold the screen - * awake between commands: the [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] flag - * 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 the agent has been quiet 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. + * 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 @@ -41,7 +41,6 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { private val windowManager = service.getSystemService(WindowManager::class.java) private var view: LiquidMetalBorderView? = null - private val hideFrameRunnable = Runnable { hideFrame() } private val removeRunnable = Runnable { removeView() } /** @@ -51,11 +50,8 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { fun signal(mode: Mode) { main.post { ensureAttached() - view?.setMode(mode) - view?.startAnimating() - main.removeCallbacks(hideFrameRunnable) + view?.poke(mode) main.removeCallbacks(removeRunnable) - main.postDelayed(hideFrameRunnable, FRAME_LINGER_MILLIS) main.postDelayed(removeRunnable, KEEP_AWAKE_MILLIS) } } @@ -63,7 +59,6 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { /** Tear down the overlay for good. Call when the service unbinds or is destroyed. */ fun destroy() { main.post { - main.removeCallbacks(hideFrameRunnable) main.removeCallbacks(removeRunnable) removeView() } @@ -90,16 +85,6 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { } } - /** - * Stop drawing the frame but keep the window attached so its - * [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] still holds the screen awake - * through gaps between commands. The view stays attached and simply paints nothing - * while idle; [signal] re-arms the animation on the next command. - */ - private fun hideFrame() { - view?.stopAnimating() - } - private fun removeView() { val overlay = view ?: return overlay.stopAnimating() @@ -108,14 +93,14 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { } private companion object { - /** How long the animated frame lingers after the last observe/control call. */ - const val FRAME_LINGER_MILLIS = 1_500L - /** - * How long the (blank, non-drawing) window stays attached after the last call - * to hold the screen awake between commands. Sized to comfortably cover pauses - * between agent actions (model latency, network) without pinning the screen - * on well past the end of a session. + * 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 } diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt index 424c7b6..47278dd 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt @@ -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() @@ -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) } @@ -68,13 +75,23 @@ 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() @@ -160,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