|
| 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 | +} |
0 commit comments