From b4b83f6c4ba67f2a1aae3569431e6d9e3cc66bb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:08:04 +0000 Subject: [PATCH 1/3] Add liquid-metal activity border for computer use Draw an animated screen-edge frame over whatever app the computer-use tools are driving, as a live "agent is acting" cue. The frame lives in a TYPE_ACCESSIBILITY_OVERLAY window added by the accessibility service, so it needs no SYSTEM_ALERT_WINDOW permission, and it is not-touchable / not-focusable so dispatched gestures pass through to the app below. - LiquidMetalBorderView: an AGSL RuntimeShader (simplex-noise FBM warp + sine bands + five-stop metallic palette, time-driven) confined to a stroked rounded rect so only the ring is painted; center stays clear. Falls back to a rotating metallic SweepGradient below API 33 or on a software canvas. - Observe and control get distinct palettes: cool silver/chrome for observe, warm gold/amber for control. - ComputerUseBorderOverlay: main-thread window controller with a debounced linger, so nothing is drawn (and no GPU work happens) while idle. - AndroidComputerSource signals OBSERVE from observe() and CONTROL from every control op via withService(); the service owns the overlay and tears it down on unbind/destroy. The frame is thin and only shows during activity; it will appear at the extreme screen edge in accessibility screenshots. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ksy8qLoAZKgVwA63ctX7Vp --- .../stdiod/mcp/AndroidComputerSource.kt | 14 + .../stdiod/mcp/ComputerUseBorderOverlay.kt | 94 +++++++ .../stdiod/mcp/LiquidMetalBorderView.kt | 245 ++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt create mode 100644 app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/AndroidComputerSource.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/AndroidComputerSource.kt index f9ef8bc..cefd21d 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/AndroidComputerSource.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/AndroidComputerSource.kt @@ -40,8 +40,11 @@ class ComputerAccessibilityService : AccessibilityService() { internal var lastEventUptimeMillis: Long = 0L private set + private var borderOverlay: ComputerUseBorderOverlay? = null + override fun onServiceConnected() { super.onServiceConnected() + borderOverlay = ComputerUseBorderOverlay(this) active = this } @@ -52,14 +55,23 @@ class ComputerAccessibilityService : AccessibilityService() { internal fun eventSequence(): Long = eventSequence.get() + /** Show/refresh the on-screen "liquid metal" activity frame for [mode]. */ + internal fun signalComputerActivity(mode: ComputerUseBorderOverlay.Mode) { + borderOverlay?.signal(mode) + } + override fun onInterrupt() = Unit override fun onUnbind(intent: Intent?): Boolean { + borderOverlay?.destroy() + borderOverlay = null if (active === this) active = null return super.onUnbind(intent) } override fun onDestroy() { + borderOverlay?.destroy() + borderOverlay = null if (active === this) active = null super.onDestroy() } @@ -115,6 +127,7 @@ class AndroidComputerSource(context: Context) : ComputerSource { override fun observe(): ComputerOperationResult { val service = availableService() ?: return unavailableResult() + service.signalComputerActivity(ComputerUseBorderOverlay.Mode.OBSERVE) return captureObservation(service) } @@ -227,6 +240,7 @@ class AndroidComputerSource(context: Context) : ComputerSource { private inline fun withService(block: (ComputerAccessibilityService) -> ComputerOperationResult): ComputerOperationResult { val service = availableService() ?: return unavailableResult() + service.signalComputerActivity(ComputerUseBorderOverlay.Mode.CONTROL) return block(service) } diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt new file mode 100644 index 0000000..4dc9378 --- /dev/null +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt @@ -0,0 +1,94 @@ +package ai.sealgate.stdiod.mcp + +import android.accessibilityservice.AccessibilityService +import android.graphics.PixelFormat +import android.os.Handler +import android.os.Looper +import android.view.WindowManager + +/** + * Screen-edge "liquid metal" indicator drawn while computer use is observing or + * controlling the device. The frame lives in a [WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY] + * window, which an [AccessibilityService] may add over any other app WITHOUT the + * SYSTEM_ALERT_WINDOW ("draw over other apps") permission. The window is not + * 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. + * + * 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 + * edge; if agent vision near the edge ever matters, hide the view around + * [AndroidComputerSource]'s screenshot capture. + */ +class ComputerUseBorderOverlay(private val service: AccessibilityService) { + + /** Which computer-use activity is happening; selects the frame palette. */ + enum class Mode { OBSERVE, CONTROL } + + private val main = Handler(Looper.getMainLooper()) + private val windowManager = service.getSystemService(WindowManager::class.java) + private var view: LiquidMetalBorderView? = null + private var attached = false + + private val hideRunnable = Runnable { removeView() } + + /** + * Mark computer-use activity of [mode]. Safe to call from any thread; the work + * is marshalled onto the main looper where the window and its animator live. + */ + fun signal(mode: Mode) { + main.post { + ensureAttached() + view?.setMode(mode) + view?.startAnimating() + main.removeCallbacks(hideRunnable) + main.postDelayed(hideRunnable, LINGER_MILLIS) + } + } + + /** Tear down the overlay for good. Call when the service unbinds or is destroyed. */ + fun destroy() { + main.post { + main.removeCallbacks(hideRunnable) + removeView() + } + } + + private fun ensureAttached() { + if (attached) return + val manager = windowManager ?: return + val overlay = LiquidMetalBorderView(service) + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + 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, + PixelFormat.TRANSLUCENT, + ) + val added = runCatching { manager.addView(overlay, params) }.isSuccess + if (added) { + view = overlay + attached = true + } + } + + private fun removeView() { + val overlay = view ?: return + overlay.stopAnimating() + runCatching { windowManager?.removeView(overlay) } + view = null + attached = false + } + + private companion object { + /** How long the frame lingers after the last observe/control call. */ + const val LINGER_MILLIS = 1_500L + } +} diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt new file mode 100644 index 0000000..add688e --- /dev/null +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt @@ -0,0 +1,245 @@ +package ai.sealgate.stdiod.mcp + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.RuntimeShader +import android.graphics.SweepGradient +import android.os.Build +import android.view.Choreographer +import android.view.View +import androidx.annotation.RequiresApi + +/** + * A full-screen, touch-transparent view that paints an animated "liquid metal" + * frame around the screen edge and nothing in the middle. The metal is a + * per-pixel shader (simplex-noise FBM warp + sine bands + a five-stop metallic + * palette, driven by a time uniform) confined to a stroked rounded rect, so only + * the border ring is filled. + * + * On Android 13+ (AGSL [RuntimeShader]) this is a near-direct port of the WebGL + * reference. Below 13 — or on a software canvas — it falls back to a rotating + * metallic [SweepGradient], which reads as a moving sheen rather than true + * refractive metal. + */ +@SuppressLint("ViewConstructor") +class LiquidMetalBorderView(context: Context) : View(context) { + + private val density = resources.displayMetrics.density + private val borderWidthPx = BORDER_WIDTH_DP * density + private val cornerRadiusPx = CORNER_RADIUS_DP * density + + private var mode: ComputerUseBorderOverlay.Mode = ComputerUseBorderOverlay.Mode.OBSERVE + private var startNanos = 0L + private var animating = false + + private val strokeRect = RectF() + + private val runtimeShader: RuntimeShader? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + runCatching { RuntimeShader(AGSL_SOURCE) }.getOrNull() + } else { + null + } + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = borderWidthPx + } + + private val frameCallback = Choreographer.FrameCallback { + if (!animating) return@FrameCallback + invalidate() + Choreographer.getInstance().postFrameCallback(this.frameCallback) + } + + init { + // No touch handling and nothing behind the ring; the window flags already + // make this pass-through, this just avoids any accidental focus. + isClickable = false + isFocusable = false + } + + fun setMode(mode: ComputerUseBorderOverlay.Mode) { + this.mode = mode + } + + fun startAnimating() { + if (animating) return + animating = true + startNanos = System.nanoTime() + Choreographer.getInstance().postFrameCallback(frameCallback) + } + + fun stopAnimating() { + animating = false + Choreographer.getInstance().removeFrameCallback(frameCallback) + } + + override fun onDetachedFromWindow() { + stopAnimating() + super.onDetachedFromWindow() + } + + override fun onDraw(canvas: Canvas) { + val inset = borderWidthPx / 2f + strokeRect.set(inset, inset, width - inset, height - inset) + val timeSeconds = (System.nanoTime() - startNanos) / 1_000_000_000f + val palette = paletteFor(mode) + + val shader = runtimeShader + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + shader != null && + canvas.isHardwareAccelerated + ) { + drawWithRuntimeShader(canvas, shader, timeSeconds, palette) + } else { + drawWithSweepFallback(canvas, timeSeconds, palette) + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun drawWithRuntimeShader( + canvas: Canvas, + shader: RuntimeShader, + timeSeconds: Float, + palette: Palette, + ) { + shader.setFloatUniform("iResolution", width.toFloat(), height.toFloat()) + shader.setFloatUniform("iTime", timeSeconds) + shader.setColorUniform("cA", palette.a) + shader.setColorUniform("cB", palette.b) + shader.setColorUniform("cC", palette.c) + shader.setColorUniform("cD", palette.d) + shader.setColorUniform("cE", palette.e) + paint.shader = shader + canvas.drawRoundRect(strokeRect, cornerRadiusPx, cornerRadiusPx, paint) + } + + private fun drawWithSweepFallback(canvas: Canvas, timeSeconds: Float, palette: Palette) { + val cx = width / 2f + val cy = height / 2f + val sweep = SweepGradient( + cx, + cy, + intArrayOf(palette.a, palette.c, palette.e, palette.c, palette.a), + floatArrayOf(0f, 0.25f, 0.5f, 0.75f, 1f), + ) + val rotation = android.graphics.Matrix().apply { + setRotate((timeSeconds * SWEEP_DEGREES_PER_SEC) % 360f, cx, cy) + } + sweep.setLocalMatrix(rotation) + paint.shader = sweep + canvas.drawRoundRect(strokeRect, cornerRadiusPx, cornerRadiusPx, paint) + } + + private fun paletteFor(mode: ComputerUseBorderOverlay.Mode): Palette = when (mode) { + // Observe: cool silver/chrome — reading, not touching. + ComputerUseBorderOverlay.Mode.OBSERVE -> Palette( + a = Color.rgb(0x3A, 0x45, 0x52), + b = Color.rgb(0x8A, 0x9B, 0xA8), + c = Color.rgb(0xE8, 0xF0, 0xF6), + d = Color.rgb(0x9F, 0xB4, 0xC4), + e = Color.rgb(0x5C, 0x74, 0x86), + ) + // Control: warm gold/amber — actively driving the device. + ComputerUseBorderOverlay.Mode.CONTROL -> Palette( + a = Color.rgb(0x5A, 0x3B, 0x0E), + b = Color.rgb(0xC9, 0x8A, 0x2B), + c = Color.rgb(0xFF, 0xF1, 0xC2), + d = Color.rgb(0xE7, 0xA8, 0x3A), + e = Color.rgb(0x8A, 0x54, 0x12), + ) + } + + private data class Palette(val a: Int, val b: Int, val c: Int, val d: Int, val e: Int) + + private companion object { + const val BORDER_WIDTH_DP = 6f + const val CORNER_RADIUS_DP = 28f + const val SWEEP_DEGREES_PER_SEC = 60f + + // AGSL (Android 13+). SKSL dialect: float2/half4, entry half4 main(float2). + val AGSL_SOURCE = """ + uniform float2 iResolution; + uniform float iTime; + layout(color) uniform half4 cA; + layout(color) uniform half4 cB; + layout(color) uniform half4 cC; + layout(color) uniform half4 cD; + layout(color) uniform half4 cE; + + float3 permute(float3 x) { return mod((x * 34.0 + 1.0) * x, 289.0); } + + // Ashima 2D simplex noise. + float snoise(float2 v) { + const float4 C = float4(0.211324865405187, 0.366025403784439, + -0.577350269189626, 0.024390243902439); + float2 i = floor(v + dot(v, C.yy)); + float2 x0 = v - i + dot(i, C.xx); + float2 i1 = (x0.x > x0.y) ? float2(1.0, 0.0) : float2(0.0, 1.0); + float4 x12 = x0.xyxy + C.xxzz; + x12.xy -= i1; + i = mod(i, 289.0); + float3 p = permute(permute(i.y + float3(0.0, i1.y, 1.0)) + + i.x + float3(0.0, i1.x, 1.0)); + float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), + dot(x12.zw, x12.zw)), 0.0); + m = m * m; + m = m * m; + float3 x = 2.0 * fract(p * C.www) - 1.0; + float3 h = abs(x) - 0.5; + float3 ox = floor(x + 0.5); + float3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + float3 g; + g.x = a0.x * x0.x + h.x * x0.y; + g.yz = a0.yz * x12.xz + h.yz * x12.yw; + return 130.0 * dot(m, g); + } + + float fbm(float2 p) { + float sum = 0.0; + float amp = 0.5; + for (int i = 0; i < 5; i++) { + sum += amp * snoise(p); + p *= 2.0; + amp *= 0.5; + } + return sum; + } + + half3 palette(float t) { + t = clamp(t, 0.0, 1.0); + const float s = 0.16; + float wa = exp(-((t - 0.00) * (t - 0.00)) / (s * s)); + float wb = exp(-((t - 0.25) * (t - 0.25)) / (s * s)); + float wc = exp(-((t - 0.50) * (t - 0.50)) / (s * s)); + float wd = exp(-((t - 0.75) * (t - 0.75)) / (s * s)); + float we = exp(-((t - 1.00) * (t - 1.00)) / (s * s)); + half3 acc = wa * cA.rgb + wb * cB.rgb + wc * cC.rgb + wd * cD.rgb + we * cE.rgb; + return acc / (wa + wb + wc + wd + we); + } + + half4 main(float2 fragCoord) { + float2 uv = fragCoord / iResolution; + float2 p = uv * 3.0; + float2 warp = float2(fbm(p + iTime * 0.15), + fbm(p + 7.3 + iTime * 0.12)); + float n = fbm(p + warp); + float bands = 0.0; + bands += sin(uv.x * 10.0 + iTime * 1.3 + n * 3.0); + bands += sin(uv.y * 8.0 - iTime * 1.1 + n * 2.5); + bands += sin((uv.x + uv.y) * 6.0 + iTime * 0.9); + bands += sin(length(uv - 0.5) * 14.0 - iTime * 1.6 + n * 2.0); + float t = bands * 0.125 + 0.5; + half3 col = palette(t); + col += half3(smoothstep(0.86, 1.0, t)) * 0.55; + return half4(clamp(col, 0.0, 1.0), 1.0); + } + """.trimIndent() + } +} From 72b044ba5a2b8ad77b5e93a4e691970d4d708eb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:24:22 +0000 Subject: [PATCH 2/3] Fix recursive type inference on frameCallback The Choreographer.FrameCallback val referenced itself in its own initializer, so Kotlin could not infer its type (compileDebugKotlin: "Type checking has run into a recursive problem"). Annotate the type explicitly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ksy8qLoAZKgVwA63ctX7Vp --- .../main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 add688e..b10b519 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt @@ -50,10 +50,10 @@ class LiquidMetalBorderView(context: Context) : View(context) { strokeWidth = borderWidthPx } - private val frameCallback = Choreographer.FrameCallback { + private val frameCallback: Choreographer.FrameCallback = Choreographer.FrameCallback { if (!animating) return@FrameCallback invalidate() - Choreographer.getInstance().postFrameCallback(this.frameCallback) + Choreographer.getInstance().postFrameCallback(frameCallback) } init { From e71c87490f951e8af5eb57509e1339ec23d04e2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 12:31:48 +0000 Subject: [PATCH 3/3] Remove per-frame allocations from liquid-metal border draw Thermo-nuclear review follow-up. - Precompute the two palettes as constants instead of allocating a Palette (and calling Color.rgb) on every onDraw frame. - Cache the fallback SweepGradient and reuse a single Matrix, rebuilding the gradient only when the mode or view size changes, instead of allocating both every frame in the ~60fps draw loop. The AGSL path already reused its compiled shader. - Hoist the single drawRoundRect out of the two draw branches, which now only prepare and return the shader. - Drop the redundant `attached` flag in ComputerUseBorderOverlay; the nullable view is the single source of truth. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ksy8qLoAZKgVwA63ctX7Vp --- .../stdiod/mcp/ComputerUseBorderOverlay.kt | 8 +- .../stdiod/mcp/LiquidMetalBorderView.kt | 81 +++++++++++-------- 2 files changed, 49 insertions(+), 40 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 4dc9378..16b5ded 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/ComputerUseBorderOverlay.kt @@ -31,7 +31,6 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { private val main = Handler(Looper.getMainLooper()) private val windowManager = service.getSystemService(WindowManager::class.java) private var view: LiquidMetalBorderView? = null - private var attached = false private val hideRunnable = Runnable { removeView() } @@ -58,7 +57,7 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { } private fun ensureAttached() { - if (attached) return + if (view != null) return val manager = windowManager ?: return val overlay = LiquidMetalBorderView(service) val params = WindowManager.LayoutParams( @@ -72,10 +71,8 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, PixelFormat.TRANSLUCENT, ) - val added = runCatching { manager.addView(overlay, params) }.isSuccess - if (added) { + if (runCatching { manager.addView(overlay, params) }.isSuccess) { view = overlay - attached = true } } @@ -84,7 +81,6 @@ class ComputerUseBorderOverlay(private val service: AccessibilityService) { overlay.stopAnimating() runCatching { windowManager?.removeView(overlay) } view = null - attached = false } private companion object { 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 b10b519..fc008b2 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/LiquidMetalBorderView.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Color +import android.graphics.Matrix import android.graphics.Paint import android.graphics.RectF import android.graphics.RuntimeShader @@ -37,6 +38,10 @@ class LiquidMetalBorderView(context: Context) : View(context) { private var animating = false private val strokeRect = RectF() + private val sweepMatrix = Matrix() + + // Fallback shader, rebuilt only when the mode or size changes (see setMode / onSizeChanged). + private var sweepShader: SweepGradient? = null private val runtimeShader: RuntimeShader? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -64,7 +69,9 @@ class LiquidMetalBorderView(context: Context) : View(context) { } fun setMode(mode: ComputerUseBorderOverlay.Mode) { + if (this.mode == mode) return this.mode = mode + sweepShader = null } fun startAnimating() { @@ -79,6 +86,11 @@ class LiquidMetalBorderView(context: Context) : View(context) { Choreographer.getInstance().removeFrameCallback(frameCallback) } + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + sweepShader = null + } + override fun onDetachedFromWindow() { stopAnimating() super.onDetachedFromWindow() @@ -88,26 +100,23 @@ class LiquidMetalBorderView(context: Context) : View(context) { val inset = borderWidthPx / 2f strokeRect.set(inset, inset, width - inset, height - inset) val timeSeconds = (System.nanoTime() - startNanos) / 1_000_000_000f - val palette = paletteFor(mode) val shader = runtimeShader - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + paint.shader = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null && canvas.isHardwareAccelerated ) { - drawWithRuntimeShader(canvas, shader, timeSeconds, palette) + metalShader(shader, timeSeconds) } else { - drawWithSweepFallback(canvas, timeSeconds, palette) + sweepFor(timeSeconds) } + canvas.drawRoundRect(strokeRect, cornerRadiusPx, cornerRadiusPx, paint) } + /** AGSL path: reuse the compiled shader, just push this frame's uniforms. */ @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private fun drawWithRuntimeShader( - canvas: Canvas, - shader: RuntimeShader, - timeSeconds: Float, - palette: Palette, - ) { + private fun metalShader(shader: RuntimeShader, timeSeconds: Float): RuntimeShader { + val palette = paletteFor(mode) shader.setFloatUniform("iResolution", width.toFloat(), height.toFloat()) shader.setFloatUniform("iTime", timeSeconds) shader.setColorUniform("cA", palette.a) @@ -115,52 +124,56 @@ class LiquidMetalBorderView(context: Context) : View(context) { shader.setColorUniform("cC", palette.c) shader.setColorUniform("cD", palette.d) shader.setColorUniform("cE", palette.e) - paint.shader = shader - canvas.drawRoundRect(strokeRect, cornerRadiusPx, cornerRadiusPx, paint) + return shader } - private fun drawWithSweepFallback(canvas: Canvas, timeSeconds: Float, palette: Palette) { - val cx = width / 2f - val cy = height / 2f - val sweep = SweepGradient( - cx, - cy, + /** Fallback path: reuse the cached gradient and matrix, just re-rotate. */ + private fun sweepFor(timeSeconds: Float): SweepGradient { + val sweep = sweepShader ?: buildSweep().also { sweepShader = it } + sweepMatrix.setRotate((timeSeconds * SWEEP_DEGREES_PER_SEC) % 360f, width / 2f, height / 2f) + sweep.setLocalMatrix(sweepMatrix) + return sweep + } + + private fun buildSweep(): SweepGradient { + val palette = paletteFor(mode) + return SweepGradient( + width / 2f, + height / 2f, intArrayOf(palette.a, palette.c, palette.e, palette.c, palette.a), floatArrayOf(0f, 0.25f, 0.5f, 0.75f, 1f), ) - val rotation = android.graphics.Matrix().apply { - setRotate((timeSeconds * SWEEP_DEGREES_PER_SEC) % 360f, cx, cy) - } - sweep.setLocalMatrix(rotation) - paint.shader = sweep - canvas.drawRoundRect(strokeRect, cornerRadiusPx, cornerRadiusPx, paint) } private fun paletteFor(mode: ComputerUseBorderOverlay.Mode): Palette = when (mode) { + ComputerUseBorderOverlay.Mode.OBSERVE -> OBSERVE_PALETTE + ComputerUseBorderOverlay.Mode.CONTROL -> CONTROL_PALETTE + } + + private data class Palette(val a: Int, val b: Int, val c: Int, val d: Int, val e: Int) + + private companion object { + const val BORDER_WIDTH_DP = 6f + const val CORNER_RADIUS_DP = 28f + const val SWEEP_DEGREES_PER_SEC = 60f + // Observe: cool silver/chrome — reading, not touching. - ComputerUseBorderOverlay.Mode.OBSERVE -> Palette( + val OBSERVE_PALETTE = Palette( a = Color.rgb(0x3A, 0x45, 0x52), b = Color.rgb(0x8A, 0x9B, 0xA8), c = Color.rgb(0xE8, 0xF0, 0xF6), d = Color.rgb(0x9F, 0xB4, 0xC4), e = Color.rgb(0x5C, 0x74, 0x86), ) + // Control: warm gold/amber — actively driving the device. - ComputerUseBorderOverlay.Mode.CONTROL -> Palette( + val CONTROL_PALETTE = Palette( a = Color.rgb(0x5A, 0x3B, 0x0E), b = Color.rgb(0xC9, 0x8A, 0x2B), c = Color.rgb(0xFF, 0xF1, 0xC2), d = Color.rgb(0xE7, 0xA8, 0x3A), e = Color.rgb(0x8A, 0x54, 0x12), ) - } - - private data class Palette(val a: Int, val b: Int, val c: Int, val d: Int, val e: Int) - - private companion object { - const val BORDER_WIDTH_DP = 6f - const val CORNER_RADIUS_DP = 28f - const val SWEEP_DEGREES_PER_SEC = 60f // AGSL (Android 13+). SKSL dialect: float2/half4, entry half4 main(float2). val AGSL_SOURCE = """