feat(android-agent): Einblendung geht weg, wenn Stefan etwas tut — statt nach fester Zeit

Eine feste Anzeigedauer ist immer geraten: mal zu kurz zum Lesen, mal steht sie
laengst im Weg. `seconds=0` liess die Einblendung bisher einfach stehen.

Jetzt heisst seconds=0: bleibt, bis der Nutzer etwas tut.

- Fernbedienung: der Accessibility-Dienst bekommt onKeyEvent und raeumt die
  Einblendung weg. Er gibt die Taste mit `return false` WEITER an den Film —
  es geht also kein Tastendruck verloren und der Player bleibt bedienbar.
  Dafuer brauchte der Dienst canRequestFilterKeyEvents + flagRequestFilterKeyEvents.
- Touch: FLAG_WATCH_OUTSIDE_TOUCH meldet eine Beruehrung ausserhalb. Die geht
  dank FLAG_NOT_TOUCHABLE gleichzeitig an die App darunter durch.
- Notbremse: nach 10 Minuten verschwindet sie in jedem Fall.

Bewusst NICHT der naheliegende Weg (Overlay faengt Tasten selbst ab): dafuer
muessten FLAG_NOT_FOCUSABLE und FLAG_NOT_TOUCHABLE weg, und dann nimmt die
Einblendung dem laufenden Film JEDE Eingabe, solange sie steht. Genau das soll
sie nicht.

host_notify beschreibt seconds=0 jetzt als den richtigen Wert fuer alles zum
Lesen (Wetter, Listen, Zahlen) und die feste Dauer fuer kurze Hinweise.

APK baut durch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:29:14 +02:00
co-authored by Claude Opus 5
parent e67659b78a
commit 4589f4abc3
4 changed files with 75 additions and 6 deletions
+12 -1
View File
@@ -1638,7 +1638,18 @@ META_TOOLS = [
"host": {"type": "string", "description": "Host-ID/Name (z.B. 'Fire-TV Schlafzimmer')."},
"text": {"type": "string", "description": "Der anzuzeigende Text."},
"title": {"type": "string", "description": "Ueberschrift (Default 'ARIA')."},
"seconds": {"type": "integer", "description": "Anzeigedauer (Default 12)."},
"seconds": {
"type": "integer",
"description": (
"Anzeigedauer in Sekunden (Default 12). "
"0 = stehen lassen, bis Stefan selbst wegdrueckt "
"(Fernbedienung: beliebige Taste, Handy: daneben "
"tippen). Nimm 0 fuer alles zum LESEN — Wetter, "
"Listen, Zahlen: eine feste Dauer ist da immer "
"geraten und meist zu kurz. Kurze Hinweise waehrend "
"eines Films ruhig mit Dauer."
),
},
"mode": {"type": "string", "description": "overlay | speech | both"},
"focus": {"type": "string", "description": "Bei Sprachausgabe: 'duck' (laufende Medien werden leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
"image_path": {
@@ -44,6 +44,29 @@ class AriaAccessibilityService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ }
override fun onInterrupt() { /* passiv */ }
/**
* Steht eine Einblendung offen, raeumt sie der naechste Tastendruck weg.
*
* Der Trick: wir geben IMMER `false` zurueck. Damit laeuft die Taste ganz
* normal weiter an den Film/die App — die Einblendung schluckt nichts. Sie
* muss dafuer auch nicht fokussierbar sein, kann also weiterhin ueber einem
* laufenden Video stehen, ohne die Fernbedienung zu blockieren.
*
* Deshalb dieser Weg und nicht "Overlay faengt Tasten selbst ab": ein
* fokussierbares Fenster wuerde der App darunter JEDE Eingabe nehmen,
* solange es steht.
*
* Eine feste Anzeigedauer ist naemlich immer falsch — mal zu kurz zum
* Lesen, mal steht sie ewig im Bild.
*/
override fun onKeyEvent(event: android.view.KeyEvent?): Boolean {
if (event != null && event.action == android.view.KeyEvent.ACTION_DOWN
&& OverlayView.isShowing()) {
OverlayView.hide(applicationContext)
}
return false // Taste NICHT verbrauchen
}
/** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */
fun dump(): JSONObject {
val root = rootInActiveWindow
@@ -25,13 +25,33 @@ import android.widget.TextView
*
* Nicht fokussierbar (FLAG_NOT_FOCUSABLE) — die Fernbedienung steuert weiter die
* App darunter, die Einblendung schluckt keine Tastendruecke.
*
* WEGBEKOMMEN ohne feste Anzeigedauer (seconds = 0):
* - Fernbedienung: der Accessibility-Dienst sieht den naechsten Tastendruck und
* raeumt die Einblendung weg — er gibt die Taste trotzdem an den Film weiter
* (siehe AriaAccessibilityService.onKeyEvent).
* - Touch: FLAG_WATCH_OUTSIDE_TOUCH meldet uns eine Beruehrung AUSSERHALB der
* Einblendung. Die Beruehrung geht gleichzeitig an die App darunter — es
* geht also kein Tipp verloren.
* - Notbremse: nach HARD_CAP_SEC verschwindet sie in jedem Fall. Ohne das
* koennte eine vergessene Einblendung dauerhaft im Bild stehen.
*
* Eine feste Dauer ist naemlich immer falsch geraten — mal zu kurz zum Lesen,
* mal steht sie laengst im Weg.
*/
object OverlayView {
/** Auch eine "bleibt stehen"-Einblendung geht spaetestens danach weg. */
private const val HARD_CAP_SEC = 600
private val main = Handler(Looper.getMainLooper())
private var root: LinearLayout? = null
private var hideRunnable: Runnable? = null
/** Steht gerade eine Einblendung? Der Accessibility-Dienst fragt das, bevor
* er auf einen Tastendruck hin aufraeumt. */
fun isShowing(): Boolean = root != null
/** Hat der Nutzer "Ueber anderen Apps anzeigen" erlaubt? */
fun canDraw(ctx: Context): Boolean =
if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
@@ -98,6 +118,10 @@ object OverlayView {
type,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
// Meldet uns Beruehrungen AUSSERHALB, ohne sie zu
// schlucken — damit raeumt ein Tipp aufs Video die
// Einblendung weg und steuert trotzdem den Player.
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
android.graphics.PixelFormat.TRANSLUCENT,
).apply {
@@ -107,13 +131,23 @@ object OverlayView {
width = (ctx.resources.displayMetrics.widthPixels * 0.42f).toInt()
}
// Beruehrung ausserhalb -> wegraeumen. Die Beruehrung selbst
// geht dank FLAG_NOT_TOUCHABLE weiter an die App darunter.
box.setOnTouchListener { _, ev ->
if (ev.action == android.view.MotionEvent.ACTION_OUTSIDE) hideNow(ctx)
false
}
wm.addView(box, lp)
root = box
if (seconds > 0) {
hideRunnable = Runnable { hideNow(ctx) }
main.postDelayed(hideRunnable!!, seconds * 1000L)
}
// seconds > 0 : feste Dauer wie gehabt.
// seconds == 0: bleibt stehen, bis der Nutzer etwas tut
// (Taste via Accessibility-Dienst / Tipp daneben)
// — mit Notbremse, damit sie nie haengen bleibt.
val cap = if (seconds > 0) seconds else HARD_CAP_SEC
hideRunnable = Runnable { hideNow(ctx) }
main.postDelayed(hideRunnable!!, cap * 1000L)
} catch (_: Exception) {
root = null
}
@@ -2,8 +2,9 @@
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds|flagRequestFilterKeyEvents"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:canRequestFilterKeyEvents="true"
android:notificationTimeout="100"
android:description="@string/accessibility_desc" />