Skip to content

Commit 2a338fc

Browse files
Add background status traffic light (top-right) driven entirely by WebView
WebView (index.html) owns the complete state machine: - Sending Screenshot -> red, Waiting for AI -> yellow, AI responds -> green - Received commands (shown verbatim, truncated) / Waiting for Termux -> black - Error / Stop: light goes out, word stays 3 s or until app is foregrounded - Yellowish small label, white ring around the colored circle - Only visible while Screen Operator is in the background (isAppInForeground poll) - All wording/colors/sizes/timing defined in JS; old APKs degrade gracefully Native side is a dumb JSON renderer only (required for drawing over other apps): - AccessibilityStatusLightOverlay: non-focusable, non-touchable TYPE_ACCESSIBILITY_OVERLAY that renders whatever JSON spec JS sends - ScreenOperatorAccessibilityService.updateStatusLight/hideStatusLight - WebViewBridge.updateStatusLight/hideStatusLight (+ dispatch routing) Future changes to the indicator need only an index.html commit, no APK build.
1 parent 96769a5 commit 2a338fc

4 files changed

Lines changed: 479 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.google.ai.sample
2+
3+
import android.content.Context
4+
import android.graphics.Color
5+
import android.graphics.PixelFormat
6+
import android.graphics.drawable.GradientDrawable
7+
import android.util.TypedValue
8+
import android.view.Gravity
9+
import android.view.View
10+
import android.view.WindowManager
11+
import android.widget.LinearLayout
12+
import android.widget.TextView
13+
import org.json.JSONObject
14+
15+
/**
16+
* Presentation-only "traffic light" status indicator shown near the top-right screen edge
17+
* while Screen Operator works in the background (small label + colored circle).
18+
*
19+
* Deliberately DUMB by design: the entire state machine (which text/color is shown when,
20+
* visibility, hold timers, foreground detection) lives in the WebView (index.html). This
21+
* class only renders a JSON payload it receives via the bridge, so any future change to
22+
* wording, colors, sizes or timing is a pure web-bundle change - no APK rebuild required.
23+
*
24+
* JSON payload keys (all optional except "text"):
25+
* text String label text (e.g. "Sending Screenshot")
26+
* textColor String hex color for the label (default "#E8D66B")
27+
* textSizeSp Double label text size in sp (default 11.0)
28+
* circleColor String inner fill color of the circle; ABSENT/empty -> no circle at all
29+
* (used for the Error/Stop states where the light is off)
30+
* ringColor String outer ring color of the circle (default "#FFFFFF")
31+
* ringWidthDp Double ring stroke width in dp (default 1.5)
32+
* circleSizeDp Double circle diameter in dp (default 10.0)
33+
* gapDp Double gap between text and circle in dp (default 5.0)
34+
* marginTopDp Double distance from the top screen edge in dp (default 4.0)
35+
* marginEndDp Double distance from the right screen edge in dp (default 6.0)
36+
* bgColor String optional pill background behind label+circle (default none)
37+
* bgCornerDp Double corner radius of the pill background in dp (default 8.0)
38+
* paddingHDp Double horizontal padding inside the pill in dp (default 0.0)
39+
* paddingVDp Double vertical padding inside the pill in dp (default 0.0)
40+
*
41+
* The window is NOT focusable and NOT touchable, so it can never intercept input or
42+
* interfere with the accessibility automation running underneath it.
43+
*/
44+
internal class AccessibilityStatusLightOverlay(private val context: Context) {
45+
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
46+
private var rootView: LinearLayout? = null
47+
private var labelView: TextView? = null
48+
private var circleView: View? = null
49+
50+
fun update(json: String) {
51+
val spec = try { JSONObject(json) } catch (_: Exception) { JSONObject() }
52+
val text = spec.optString("text", "").take(200)
53+
if (text.isEmpty()) {
54+
dismiss()
55+
return
56+
}
57+
58+
val textColor = parseColor(spec.optString("textColor"), Color.rgb(232, 214, 107))
59+
val textSizeSp = spec.optDouble("textSizeSp", 11.0).toFloat()
60+
val circleColorRaw = spec.optString("circleColor", "")
61+
val showCircle = circleColorRaw.isNotBlank()
62+
val circleColor = parseColor(circleColorRaw, Color.BLACK)
63+
val ringColor = parseColor(spec.optString("ringColor"), Color.WHITE)
64+
val ringWidthPx = dp(spec.optDouble("ringWidthDp", 1.5))
65+
val circleSizePx = dp(spec.optDouble("circleSizeDp", 10.0))
66+
val gapPx = dp(spec.optDouble("gapDp", 5.0))
67+
val marginTopPx = dp(spec.optDouble("marginTopDp", 4.0))
68+
val marginEndPx = dp(spec.optDouble("marginEndDp", 6.0))
69+
val bgColorRaw = spec.optString("bgColor", "")
70+
val paddingHPx = dp(spec.optDouble("paddingHDp", 0.0))
71+
val paddingVPx = dp(spec.optDouble("paddingVDp", 0.0))
72+
73+
val root = rootView ?: createViews()
74+
val label = labelView ?: return
75+
val circle = circleView ?: return
76+
77+
label.text = text
78+
label.setTextColor(textColor)
79+
label.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp)
80+
81+
if (showCircle) {
82+
circle.visibility = View.VISIBLE
83+
circle.background = GradientDrawable().apply {
84+
shape = GradientDrawable.OVAL
85+
setColor(circleColor)
86+
setStroke(ringWidthPx.coerceAtLeast(1), ringColor)
87+
}
88+
circle.layoutParams = (circle.layoutParams as LinearLayout.LayoutParams).apply {
89+
width = circleSizePx
90+
height = circleSizePx
91+
marginStart = gapPx
92+
}
93+
} else {
94+
circle.visibility = View.GONE
95+
}
96+
97+
root.background = if (bgColorRaw.isNotBlank()) {
98+
GradientDrawable().apply {
99+
shape = GradientDrawable.RECTANGLE
100+
setColor(parseColor(bgColorRaw, Color.TRANSPARENT))
101+
cornerRadius = dp(spec.optDouble("bgCornerDp", 8.0)).toFloat()
102+
}
103+
} else {
104+
null
105+
}
106+
root.setPadding(paddingHPx, paddingVPx, paddingHPx, paddingVPx)
107+
108+
val params = windowParams(marginEndPx, marginTopPx)
109+
if (root.parent == null) {
110+
try {
111+
windowManager.addView(root, params)
112+
} catch (_: Exception) {
113+
// If the window can't be added (e.g. service shutting down), fail silently -
114+
// this is a purely cosmetic indicator and must never break command execution.
115+
rootView = null
116+
labelView = null
117+
circleView = null
118+
}
119+
} else {
120+
try {
121+
windowManager.updateViewLayout(root, params)
122+
} catch (_: Exception) {
123+
// View got detached in between; drop and let the next update recreate it.
124+
rootView = null
125+
labelView = null
126+
circleView = null
127+
}
128+
}
129+
}
130+
131+
fun dismiss() {
132+
val view = rootView ?: return
133+
rootView = null
134+
labelView = null
135+
circleView = null
136+
try {
137+
windowManager.removeView(view)
138+
} catch (_: Exception) {
139+
// The system may already have detached accessibility overlays during shutdown.
140+
}
141+
}
142+
143+
private fun createViews(): LinearLayout {
144+
val label = TextView(context).apply {
145+
setSingleLine(true)
146+
ellipsize = android.text.TextUtils.TruncateAt.END
147+
includeFontPadding = false
148+
}
149+
val circle = View(context)
150+
val root = LinearLayout(context).apply {
151+
orientation = LinearLayout.HORIZONTAL
152+
gravity = Gravity.CENTER_VERTICAL
153+
addView(label, LinearLayout.LayoutParams(
154+
LinearLayout.LayoutParams.WRAP_CONTENT,
155+
LinearLayout.LayoutParams.WRAP_CONTENT
156+
))
157+
addView(circle, LinearLayout.LayoutParams(dp(10.0), dp(10.0)))
158+
}
159+
rootView = root
160+
labelView = label
161+
circleView = circle
162+
return root
163+
}
164+
165+
private fun windowParams(xOffset: Int, yOffset: Int) = WindowManager.LayoutParams(
166+
WindowManager.LayoutParams.WRAP_CONTENT,
167+
WindowManager.LayoutParams.WRAP_CONTENT,
168+
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
169+
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
170+
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
171+
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
172+
PixelFormat.TRANSLUCENT
173+
).apply {
174+
gravity = Gravity.TOP or Gravity.END
175+
x = xOffset
176+
y = yOffset
177+
setTitle("Screen Operator status")
178+
}
179+
180+
private fun dp(value: Double): Int =
181+
(value * context.resources.displayMetrics.density + 0.5).toInt()
182+
183+
private fun parseColor(value: String?, fallback: Int): Int =
184+
try {
185+
if (value.isNullOrBlank()) fallback else Color.parseColor(value)
186+
} catch (_: Exception) {
187+
fallback
188+
}
189+
}

app/src/main/kotlin/com/google/ai/sample/ScreenOperatorAccessibilityService.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,39 @@ class ScreenOperatorAccessibilityService : AccessibilityService() {
144144
return true
145145
}
146146

147+
/**
148+
* Renders (or updates) the small background status "traffic light" near the top-right
149+
* screen edge. Presentation-only: the WebView owns the entire state machine and sends
150+
* a ready-to-render JSON spec (see AccessibilityStatusLightOverlay for the schema).
151+
* Returns false when the service isn't connected so JS can know nothing was shown.
152+
*/
153+
fun updateStatusLight(json: String): Boolean {
154+
val instance = serviceInstance
155+
if (!isServiceConnected.get() || instance == null) return false
156+
mainHandler.post {
157+
try {
158+
val overlay = instance.statusLightOverlay
159+
?: AccessibilityStatusLightOverlay(instance).also { instance.statusLightOverlay = it }
160+
overlay.update(json)
161+
} catch (error: Exception) {
162+
Log.e(TAG, "Could not update status light overlay", error)
163+
}
164+
}
165+
return true
166+
}
167+
168+
/** Removes the background status light overlay, if it is currently shown. */
169+
fun hideStatusLight() {
170+
val instance = serviceInstance ?: return
171+
mainHandler.post {
172+
try {
173+
instance.statusLightOverlay?.dismiss()
174+
} catch (error: Exception) {
175+
Log.e(TAG, "Could not hide status light overlay", error)
176+
}
177+
}
178+
}
179+
147180
/**
148181
* Show a toast message on the main thread
149182
*/
@@ -172,6 +205,7 @@ class ScreenOperatorAccessibilityService : AccessibilityService() {
172205
private var pendingDelayedScreenshotRunnable: Runnable? = null
173206
private var sawNonTermuxCommandSinceLastScreenshot: Boolean = false
174207
private var questionOverlay: AccessibilityQuestionOverlay? = null
208+
internal var statusLightOverlay: AccessibilityStatusLightOverlay? = null
175209

176210
private fun showQuestionOverlayInternal(
177211
question: String,
@@ -900,6 +934,8 @@ class ScreenOperatorAccessibilityService : AccessibilityService() {
900934
override fun onDestroy() {
901935
questionOverlay?.dismiss()
902936
questionOverlay = null
937+
statusLightOverlay?.dismiss()
938+
statusLightOverlay = null
903939
super.onDestroy()
904940
Log.d(TAG, "Accessibility service destroyed")
905941

app/src/main/kotlin/com/google/ai/sample/WebViewBridge.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,22 @@ class WebViewBridge(private val mainActivity: MainActivity) {
929929
)
930930
}
931931

932+
// ── Background status light ("traffic light") ───────────────────────────
933+
// Presentation-only, like showQuestionOverlay: the WebView owns the whole state machine
934+
// (which text/color when, visibility, timers) and just sends a ready-to-render JSON spec.
935+
// Any future change to wording, colors, sizes or timing is a pure index.html change.
936+
937+
@JavascriptInterface
938+
fun updateStatusLight(json: String): Boolean {
939+
val safeJson = json.take(4_000)
940+
return ScreenOperatorAccessibilityService.updateStatusLight(safeJson)
941+
}
942+
943+
@JavascriptInterface
944+
fun hideStatusLight() {
945+
ScreenOperatorAccessibilityService.hideStatusLight()
946+
}
947+
932948
// ── Toast ────────────────────────────────────────────────────────────────
933949
// Generic bridge method to show an Android Toast from JavaScript. Exists so a
934950
// custom-action-types.json entry (e.g. an AI-emitted toast("message") command) can show
@@ -1296,6 +1312,9 @@ class WebViewBridge(private val mainActivity: MainActivity) {
12961312
a.getString("question"), a.getString("answersJson")
12971313
).toString()
12981314
"showToast" -> { showToast(a.getString("message"), a.optBoolean("isLong", false)); "" }
1315+
// ── Background status light ───────────────────────────────────
1316+
"updateStatusLight" -> updateStatusLight(a.optString("json", a.toString())).toString()
1317+
"hideStatusLight" -> { hideStatusLight(); "" }
12991318
// ── Device Control ────────────────────────────────────────────
13001319
"tapByText" -> { tapByText(a.getString("buttonText")); "" }
13011320
"longTapByText" -> { longTapByText(a.getString("buttonText")); "" }

0 commit comments

Comments
 (0)