From be202b1fa1b0cf53af9fc85df98a6fbc85e93b8e Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 24 Sep 2026 20:22:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(android-agent):=20Meilenstein=202=20?= =?UTF-8?q?=E2=80=94=20sehen=20(Screenshot=20+=20ui=5Fdump)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot via MediaProjection (ScreenCapturer, gleicher {format,bytes,base64}- Vertrag wie der Desktop-Agent -> host_screenshot, inkl. Vision). UI-Baum via AriaAccessibilityService (nur lesend) -> neues Brain-Tool host_ui_dump. Freigabe einmalig in der App: 'Bildschirm-Zugriff erlauben' + 'Bedienungshilfe oeffnen'. caps = [info, screenshot, ui_dump]. targetSdk 33 -> keine mediaProjection-FGS- Typpflicht. Co-Authored-By: Claude Opus 4.8 --- aria-brain/agent.py | 38 ++++- host-agent/android/README.md | 14 +- .../android/app/src/main/AndroidManifest.xml | 13 ++ .../de/hackersoft/ariaagent/AgentService.kt | 17 +++ .../ariaagent/AriaAccessibilityService.kt | 91 ++++++++++++ .../de/hackersoft/ariaagent/MainActivity.kt | 34 +++++ .../java/de/hackersoft/ariaagent/RvsClient.kt | 30 +++- .../de/hackersoft/ariaagent/ScreenCapturer.kt | 132 ++++++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 28 ++++ .../app/src/main/res/values/strings.xml | 1 + .../src/main/res/xml/accessibility_config.xml | 8 ++ 11 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AriaAccessibilityService.kt create mode 100644 host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ScreenCapturer.kt create mode 100644 host-agent/android/app/src/main/res/xml/accessibility_config.xml diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 5561dda..e2a57e6 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1270,7 +1270,8 @@ META_TOOLS = [ "description": ( "Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT " "laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen " - "(exec/read/write/info/screenshot). Ein Host-Agent gibt Dir vollen " + "(exec/read/write/info/screenshot/ui_dump; Android-Agenten koennen " + "screenshot+ui_dump). Ein Host-Agent gibt Dir vollen " "Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall " "sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' " "machen will, das kein Geraet im Netz ist." @@ -1364,6 +1365,25 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "host_ui_dump", + "description": ( + "Liest die sichtbaren Bedienelemente eines Host-Agenten als strukturierte " + "Liste (Text, Beschriftung, Klasse, Bildschirm-Position x/y, Rahmen, ob " + "klickbar/editierbar). Vor allem fuer Android-Agenten: ergaenzt " + "host_screenshot — der Screenshot zeigt Dir das Bild, ui_dump liefert die " + "exakten Element-Texte und Koordinaten, um spaeter gezielt zu tippen. Setzt " + "auf dem Geraet eine aktive Bedienungshilfe voraus." + ), + "parameters": { + "type": "object", + "properties": {"host": {"type": "string", "description": "Host-ID/Name."}}, + "required": ["host"], + }, + }, + }, { "type": "function", "function": { @@ -2652,6 +2672,20 @@ class Agent: f"[FILE: {fp}] in deine Antwort — dann erscheint das Bild inline im Chat." ) + if name == "host_ui_dump": + result = _post("/internal/host", {"host": host, "action": "ui_dump", + "params": {}}, 20) + if not result.get("ok"): + return f"FEHLER: {result.get('error')}" + r = result.get("result") or {} + nodes = r.get("nodes") or [] + return ( + f"UI-Baum von {host} (App: {r.get('package', '?')}, " + f"{r.get('count', len(nodes))} Elemente). Jeder Eintrag hat x/y = " + f"Mittelpunkt zum Antippen:\n" + + json.dumps(nodes, ensure_ascii=False, indent=2) + ) + return f"FEHLER: unbekanntes Host-Tool {name}" except Exception as exc: return f"FEHLER: Host/Bridge nicht erreichbar: {exc}" @@ -3449,7 +3483,7 @@ class Agent: if name in ("satellite_list", "satellite_devices", "satellite_command"): return self._dispatch_satellite(name, arguments) if name in ("host_list", "host_exec", "host_read", "host_write", - "host_info", "host_screenshot"): + "host_info", "host_screenshot", "host_ui_dump"): return self._dispatch_host(name, arguments) if name == "vm_register": pid = (project_id or "").strip() diff --git a/host-agent/android/README.md b/host-agent/android/README.md index b8b0a9b..89bd2f4 100644 --- a/host-agent/android/README.md +++ b/host-agent/android/README.md @@ -103,11 +103,14 @@ identisch, nur der Wecker ändert sich. ## Meilensteine -1. **Verbinden + sichtbar** — Gradle-Projekt, AndroidManifest, RVS-WS-Client, +1. **✅ Verbinden + sichtbar** — Gradle-Projekt, AndroidManifest, RVS-WS-Client, Foreground-Service, Connect-UI (QR-Scan + manuell), `host_hello`/`host_ping`. - → Agent erscheint in der Diagnostic. Noch keine Steuerung. -2. **Sehen** — MediaProjection-Screenshot + `ui_dump` (AccessibilityService, - read-only). → ARIA kann den Schirm ansehen und beschreiben. + → Agent erscheint in der Diagnostic. `info` funktioniert. +2. **✅ Sehen** — MediaProjection-Screenshot (`ScreenCapturer`, gleicher + `{format,bytes,base64}`-Vertrag wie der Desktop-Agent → `host_screenshot`) + + `ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`). + Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe + öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten. 3. **Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den AccessibilityService. → ARIA bedient Apps (E-Mail-Setup). 4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh` @@ -124,4 +127,5 @@ cd host-agent/android APK wird manuell aufs Zielgerät kopiert + installiert (unbekannte Quellen erlauben). Gitea-Release macht den Download einfach (wie die Haupt-App). -> Status: **Design.** Als Nächstes Meilenstein 1 (Verbinden + sichtbar). +> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`). +> Als Nächstes Meilenstein 3 (Steuern). diff --git a/host-agent/android/app/src/main/AndroidManifest.xml b/host-agent/android/app/src/main/AndroidManifest.xml index c165528..ba6755b 100644 --- a/host-agent/android/app/src/main/AndroidManifest.xml +++ b/host-agent/android/app/src/main/AndroidManifest.xml @@ -39,5 +39,18 @@ + + + + + + + diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentService.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentService.kt index 4908726..96fcdbd 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentService.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentService.kt @@ -23,6 +23,9 @@ class AgentService : Service() { private const val CH = "aria_agent" private const val NOTIF_ID = 1 const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS" + const val ACTION_PROJECTION = "de.hackersoft.ariaagent.PROJECTION" + const val EXTRA_RESULT_CODE = "resultCode" + const val EXTRA_RESULT_DATA = "resultData" @Volatile var status: String = "gestoppt" @Volatile var connected: Boolean = false @@ -44,6 +47,19 @@ class AgentService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_PROJECTION) { + val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0) + @Suppress("DEPRECATION") + val data = intent.getParcelableExtra(EXTRA_RESULT_DATA) + if (code != 0 && data != null) ScreenCapturer.start(applicationContext, code, data) + if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen + return START_STICKY + } + return startRvs() + } + + /** (Re-)Startet die RVS-Verbindung anhand der gespeicherten Config. */ + private fun startRvs(): Int { val cfg = AgentConfig.load(this) if (!cfg.isValid()) { stopSelf() @@ -62,6 +78,7 @@ class AgentService : Service() { override fun onDestroy() { rvs?.stop() + ScreenCapturer.stop() connected = false status = "gestoppt" sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName)) diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AriaAccessibilityService.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AriaAccessibilityService.kt new file mode 100644 index 0000000..b76aed5 --- /dev/null +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AriaAccessibilityService.kt @@ -0,0 +1,91 @@ +package de.hackersoft.ariaagent + +import android.accessibilityservice.AccessibilityService +import android.graphics.Rect +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import org.json.JSONArray +import org.json.JSONObject + +/** + * Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND). + * + * Liefert einen strukturierten Baum der sichtbaren Bildschirm-Elemente: Text, + * Beschriftung (contentDescription), Klasse, Bildschirm-Position (Mittelpunkt + + * Rahmen) und Flags (klickbar/editierbar/ankreuzbar). ARIA nutzt das ergaenzend + * zum Screenshot, um Ziele exakt zu benennen. Tippen/Text folgt in M3. + * + * Der Nutzer schaltet den Dienst einmalig unter Einstellungen > Bedienungshilfen + * frei. Er fuehrt hier nichts autonom aus — reagiert nur auf `dump()`. + */ +class AriaAccessibilityService : AccessibilityService() { + + override fun onServiceConnected() { + instance = this + } + + override fun onUnbind(intent: android.content.Intent?): Boolean { + if (instance === this) instance = null + return super.onUnbind(intent) + } + + override fun onDestroy() { + if (instance === this) instance = null + super.onDestroy() + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ } + override fun onInterrupt() { /* passiv */ } + + /** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */ + fun dump(): JSONObject { + val root = rootInActiveWindow + ?: return JSONObject().put("ok", false) + .put("error", "Kein aktives Fenster lesbar (Bildschirm evtl. aus oder gesperrt).") + val nodes = JSONArray() + try { + walk(root, nodes, 0) + } finally { + @Suppress("DEPRECATION") try { root.recycle() } catch (_: Exception) {} + } + val result = JSONObject() + .put("package", root.packageName?.toString() ?: "") + .put("count", nodes.length()) + .put("nodes", nodes) + return JSONObject().put("ok", true).put("result", result) + } + + private fun walk(node: AccessibilityNodeInfo?, out: JSONArray, depth: Int) { + if (node == null || depth > 40 || out.length() >= 400) return + val text = node.text?.toString()?.trim() + val desc = node.contentDescription?.toString()?.trim() + val cls = node.className?.toString()?.substringAfterLast('.') + val interesting = !text.isNullOrBlank() || !desc.isNullOrBlank() || + node.isClickable || node.isEditable || node.isCheckable + if (interesting) { + val r = Rect() + node.getBoundsInScreen(r) + val o = JSONObject() + if (!text.isNullOrBlank()) o.put("text", text) + if (!desc.isNullOrBlank()) o.put("desc", desc) + if (cls != null) o.put("cls", cls) + if (node.isClickable) o.put("clickable", true) + if (node.isEditable) o.put("editable", true) + if (node.isCheckable) o.put("checked", node.isChecked) + o.put("x", r.centerX()) + o.put("y", r.centerY()) + o.put("bounds", "${r.left},${r.top},${r.right},${r.bottom}") + out.put(o) + } + for (i in 0 until node.childCount) { + walk(node.getChild(i), out, depth + 1) + } + } + + companion object { + @Volatile + var instance: AriaAccessibilityService? = null + + val available: Boolean get() = instance != null + } +} diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt index dbd00c2..a33e54c 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt @@ -1,13 +1,16 @@ package de.hackersoft.ariaagent import android.Manifest +import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.media.projection.MediaProjectionManager import android.os.Build import android.os.Bundle +import android.provider.Settings import android.widget.Button import android.widget.EditText import android.widget.TextView @@ -38,6 +41,21 @@ class MainActivity : AppCompatActivity() { } private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + private val projection = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { res -> + if (res.resultCode == Activity.RESULT_OK && res.data != null) { + val i = Intent(this, AgentService::class.java) + .setAction(AgentService.ACTION_PROJECTION) + .putExtra(AgentService.EXTRA_RESULT_CODE, res.resultCode) + .putExtra(AgentService.EXTRA_RESULT_DATA, res.data) + ContextCompat.startForegroundService(this, i) + toast("Bildschirm-Zugriff aktiv — ARIA kann jetzt Screenshots machen") + } else { + toast("Bildschirm-Zugriff abgelehnt") + } + } + private val statusReceiver = object : BroadcastReceiver() { override fun onReceive(c: Context?, i: Intent?) = refreshStatus() } @@ -64,6 +82,22 @@ class MainActivity : AppCompatActivity() { AgentService.stop(this) refreshStatus() } + findViewById