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..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,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 @@ -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 @@ -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() } } @@ -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) { @@ -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 } } 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..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() @@ -82,8 +99,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 +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 @@ -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