From 2038b676e6c1a423f35421cc9bf60a2e8ad9ec5f Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 24 Sep 2026 21:53:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(android-agent):=20Meilenstein=203=20?= =?UTF-8?q?=E2=80=94=20steuern=20(tap/text/swipe/key/app=5Flaunch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedienungshilfe kann jetzt bedienen: canPerformGestures + dispatchGesture (tap/ swipe), ACTION_SET_TEXT (text), performGlobalAction (back/home/recents/notif). app_launch via getLaunchIntentForPackage (+ Label-Suche, QUERY_ALL_PACKAGES). caps erweitert. Brain-Tools host_ui_tap/_text/_swipe/_key/host_app_launch -> generischer _UI_ACTIONS-Dispatch. Tap-Koordinaten = ui_dump-x/y (echte Pixel, nicht der skalierte Screenshot). Damit voller Ablauf sehen->steuern. Version 0.0.0.6. Co-Authored-By: Claude Opus 4.8 --- aria-brain/agent.py | 123 +++++++++++++++++- host-agent/android/README.md | 16 ++- host-agent/android/app/build.gradle | 4 +- .../android/app/src/main/AndroidManifest.xml | 2 + .../ariaagent/AriaAccessibilityService.kt | 85 ++++++++++++ .../java/de/hackersoft/ariaagent/RvsClient.kt | 85 +++++++++++- .../src/main/res/xml/accessibility_config.xml | 1 + 7 files changed, 300 insertions(+), 16 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index e2a57e6..122fe11 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1384,6 +1384,107 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "host_ui_tap", + "description": ( + "Tippt auf einem Host-Agenten (v.a. Android) auf eine Bildschirm-Position. " + "Nutze die x/y-Koordinaten AUS host_ui_dump (Element-Mittelpunkt, echte " + "Bildschirm-Pixel) — NICHT aus dem Screenshot (der ist skaliert). Ablauf: " + "erst host_ui_dump/host_screenshot (sehen), dann host_ui_tap (steuern)." + ), + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "Host-ID/Name."}, + "x": {"type": "integer", "description": "X (Pixel, aus ui_dump)."}, + "y": {"type": "integer", "description": "Y (Pixel, aus ui_dump)."}, + }, + "required": ["host", "x", "y"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "host_ui_text", + "description": ( + "Schreibt Text in ein Eingabefeld an Position x/y (aus host_ui_dump, " + "editable=true). Tippe ggf. vorher mit host_ui_tap ins Feld, damit es " + "fokussiert ist." + ), + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "Host-ID/Name."}, + "x": {"type": "integer", "description": "X des Feldes (aus ui_dump)."}, + "y": {"type": "integer", "description": "Y des Feldes (aus ui_dump)."}, + "text": {"type": "string", "description": "Einzugebender Text."}, + }, + "required": ["host", "x", "y", "text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "host_ui_swipe", + "description": ( + "Wischt/scrollt auf einem Host-Agenten von (x1,y1) nach (x2,y2). Zum " + "Scrollen nach unten z.B. von weiter unten nach weiter oben wischen." + ), + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "Host-ID/Name."}, + "x1": {"type": "integer"}, "y1": {"type": "integer"}, + "x2": {"type": "integer"}, "y2": {"type": "integer"}, + "duration_ms": {"type": "integer", "description": "Dauer in ms (Default 300)."}, + }, + "required": ["host", "x1", "y1", "x2", "y2"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "host_ui_key", + "description": ( + "Drueckt eine globale Taste auf einem Host-Agenten (Android): " + "back, home, recents, notifications." + ), + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "Host-ID/Name."}, + "key": {"type": "string", + "description": "back | home | recents | notifications"}, + }, + "required": ["host", "key"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "host_app_launch", + "description": ( + "Startet eine App auf einem Host-Agenten (Android) — per Paketname " + "(package, z.B. 'com.google.android.gm') ODER Namens-Suche (query, z.B. " + "'Einstellungen'). Danach mit host_screenshot/host_ui_dump weiterarbeiten." + ), + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "Host-ID/Name."}, + "package": {"type": "string", "description": "Paketname (optional)."}, + "query": {"type": "string", "description": "App-Name/Teilstring (optional)."}, + }, + "required": ["host"], + }, + }, + }, { "type": "function", "function": { @@ -2686,6 +2787,24 @@ class Agent: + json.dumps(nodes, ensure_ascii=False, indent=2) ) + # ── Steuern (Meilenstein 3) ────────────────────────────── + _UI_ACTIONS = { + "host_ui_tap": ("ui_tap", ["x", "y"]), + "host_ui_text": ("ui_text", ["x", "y", "text"]), + "host_ui_swipe": ("ui_swipe", ["x1", "y1", "x2", "y2", "duration_ms"]), + "host_ui_key": ("ui_key", ["key"]), + "host_app_launch": ("app_launch", ["package", "query"]), + } + if name in _UI_ACTIONS: + action, keys = _UI_ACTIONS[name] + params = {k: arguments[k] for k in keys if arguments.get(k) is not None} + result = _post("/internal/host", + {"host": host, "action": action, "params": params}, 20) + if not result.get("ok"): + return f"FEHLER: {result.get('error')}" + r = result.get("result") or {} + return f"OK ({host}): {r.get('message', 'ausgefuehrt')}" + return f"FEHLER: unbekanntes Host-Tool {name}" except Exception as exc: return f"FEHLER: Host/Bridge nicht erreichbar: {exc}" @@ -3483,7 +3602,9 @@ 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_ui_dump"): + "host_info", "host_screenshot", "host_ui_dump", + "host_ui_tap", "host_ui_text", "host_ui_swipe", + "host_ui_key", "host_app_launch"): 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 988ba41..802014f 100644 --- a/host-agent/android/README.md +++ b/host-agent/android/README.md @@ -111,10 +111,12 @@ identisch, nur der Wecker ändert sich. `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` - (Version-Param → Gitea-Release-Asset, wie die App). +3. **✅ Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den + AccessibilityService (`canPerformGestures`, `dispatchGesture`, `ACTION_SET_TEXT`, + `performGlobalAction`) → Brain-Tools `host_ui_tap`/`_text`/`_swipe`/`_key`/ + `host_app_launch`. Tap-Koordinaten = die x/y aus `ui_dump` (echte Pixel), NICHT + aus dem (skalierten) Screenshot. → ARIA bedient Apps (E-Mail-Setup). +4. **Feinschliff** — `app_list`/`notify`, Build-Härtung, `release_agent.sh`. ## Bauen @@ -190,5 +192,7 @@ Gitea-Zugang (`GITEA_URL`, `GITEA_REPO`, `GITEA_USER`) kommt aus der Umgebung od einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter „Releases", nie im Tree.** -> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`), -> `release_agent.sh` vorhanden. Als Nächstes Meilenstein 3 (Steuern). +> Status: **M1–M3 fertig** — verbinden, `info`, `screenshot`, `ui_dump` (sehen) +> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern); +> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern +> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4). diff --git a/host-agent/android/app/build.gradle b/host-agent/android/app/build.gradle index 120fb0a..17a0335 100644 --- a/host-agent/android/app/build.gradle +++ b/host-agent/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId 'de.hackersoft.ariaagent' minSdk 26 targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34 - versionCode 5 - versionName '0.0.0.5' + versionCode 6 + versionName '0.0.0.6' } // Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit diff --git a/host-agent/android/app/src/main/AndroidManifest.xml b/host-agent/android/app/src/main/AndroidManifest.xml index e03e3c6..8ebd8bb 100644 --- a/host-agent/android/app/src/main/AndroidManifest.xml +++ b/host-agent/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + $x2,$y2)") + } + + private fun gesture(path: Path, startMs: Long, durationMs: Long, desc: String): JSONObject { + val g = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, startMs, durationMs)) + .build() + val latch = CountDownLatch(1) + val ok = AtomicBoolean(false) + val dispatched = dispatchGesture(g, object : GestureResultCallback() { + override fun onCompleted(d: GestureDescription?) { ok.set(true); latch.countDown() } + override fun onCancelled(d: GestureDescription?) { latch.countDown() } + }, null) + if (!dispatched) return errMsg("Geste konnte nicht ausgeloest werden ($desc)") + try { latch.await(6, TimeUnit.SECONDS) } catch (_: InterruptedException) {} + return if (ok.get()) okMsg("$desc ausgefuehrt") else errMsg("$desc abgebrochen/timeout") + } + + /** Schreibt Text in ein Eingabefeld an (x,y) — oder in das fokussierte Feld. */ + fun setText(x: Int, y: Int, text: String): JSONObject { + val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster") + val node = editableAt(root, x, y) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)?.takeIf { it.isEditable } + ?: return errMsg("kein Textfeld an ($x,$y) gefunden — vorher ui_tap aufs Feld?") + node.performAction(AccessibilityNodeInfo.ACTION_FOCUS) + val args = Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text) + } + val done = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + return if (done) okMsg("Text gesetzt (${text.length} Zeichen)") + else errMsg("Text setzen fehlgeschlagen (Feld nicht editierbar?)") + } + + /** Tiefste editierbare Node, deren Rahmen (x,y) enthaelt. */ + private fun editableAt(node: AccessibilityNodeInfo?, x: Int, y: Int): AccessibilityNodeInfo? { + if (node == null) return null + var found: AccessibilityNodeInfo? = null + for (i in 0 until node.childCount) { + editableAt(node.getChild(i), x, y)?.let { found = it } + } + if (found != null) return found + val r = Rect(); node.getBoundsInScreen(r) + return if (node.isEditable && r.contains(x, y)) node else null + } + + /** Globale Taste: back/home/recents/notifications. */ + fun globalKey(name: String): JSONObject { + val action = when (name.trim().lowercase()) { + "back", "zurueck", "zurück" -> GLOBAL_ACTION_BACK + "home", "start", "startseite" -> GLOBAL_ACTION_HOME + "recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS + "notifications", "benachrichtigungen" -> GLOBAL_ACTION_NOTIFICATIONS + else -> return errMsg("Taste '$name' unbekannt (back/home/recents/notifications)") + } + return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt") + else errMsg("Taste '$name' fehlgeschlagen") + } + + private fun okMsg(m: String): JSONObject = + JSONObject().put("ok", true).put("result", JSONObject().put("message", m)) + + private fun errMsg(m: String): JSONObject = + JSONObject().put("ok", false).put("error", m) + companion object { @Volatile var instance: AriaAccessibilityService? = null diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt index cec9ccb..fb4ca92 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt @@ -33,7 +33,10 @@ class RvsClient( @Volatile private var running = false private var pingThread: Thread? = null - private val caps = listOf("info", "screenshot", "ui_dump") // M3: ui_tap/ui_text/… + private val caps = listOf( + "info", "screenshot", "ui_dump", + "ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch", + ) fun start() { running = true @@ -126,6 +129,7 @@ class RvsClient( && !target.equals(config.displayName(), true)) return val action = payload.optString("action") + val params = payload.optJSONObject("params") ?: JSONObject() // WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception // ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer // Fehlermeldung nur einen Timeout (kein Result kommt zurueck). @@ -136,9 +140,13 @@ class RvsClient( action == "info" -> doInfo() action == "screenshot" -> doScreenshot() action == "ui_dump" -> doUiDump() - action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key", - "app_launch", "app_list", "notify") -> - err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).") + action == "ui_tap" -> doUiTap(params) + action == "ui_text" -> doUiText(params) + action == "ui_swipe" -> doUiSwipe(params) + action == "ui_key" -> doUiKey(params) + action == "app_launch" -> doAppLaunch(params) + action in listOf("app_list", "notify") -> + err("Aktion '$action' kommt spaeter (noch nicht implementiert).") else -> err("Aktion '$action' unbekannt.") } } catch (t: Throwable) { @@ -168,12 +176,75 @@ class RvsClient( /** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */ private fun doUiDump(): JSONObject { - val svc = AriaAccessibilityService.instance - ?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " + - "öffnen' antippen und 'ARIA Host-Agent' einschalten.") + val svc = a11y() ?: return a11yMissing() return svc.dump() } + private fun a11y(): AriaAccessibilityService? = AriaAccessibilityService.instance + + private fun a11yMissing(): JSONObject = + err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe öffnen' " + + "antippen und 'ARIA Host-Agent' einschalten.") + + /** Tippen auf Koordinaten (Pixel wie in ui_dump x/y). */ + private fun doUiTap(p: JSONObject): JSONObject { + val svc = a11y() ?: return a11yMissing() + if (!p.has("x") || !p.has("y")) return err("ui_tap braucht x und y (aus ui_dump).") + return svc.tap(p.optInt("x"), p.optInt("y")) + } + + /** Text in Feld an (x,y) schreiben. */ + private fun doUiText(p: JSONObject): JSONObject { + val svc = a11y() ?: return a11yMissing() + val text = p.optString("text") + if (!p.has("x") || !p.has("y")) return err("ui_text braucht x, y und text.") + return svc.setText(p.optInt("x"), p.optInt("y"), text) + } + + /** Wischen/Scrollen von (x1,y1) nach (x2,y2). */ + private fun doUiSwipe(p: JSONObject): JSONObject { + val svc = a11y() ?: return a11yMissing() + if (!p.has("x1") || !p.has("y1") || !p.has("x2") || !p.has("y2")) + return err("ui_swipe braucht x1,y1,x2,y2 (optional duration_ms).") + return svc.swipe(p.optInt("x1"), p.optInt("y1"), p.optInt("x2"), p.optInt("y2"), + p.optInt("duration_ms", 300)) + } + + /** Globale Taste: back/home/recents/notifications. */ + private fun doUiKey(p: JSONObject): JSONObject { + val svc = a11y() ?: return a11yMissing() + return svc.globalKey(p.optString("key")) + } + + /** App starten (per Paketname oder Namens-Suche). */ + private fun doAppLaunch(p: JSONObject): JSONObject { + val pm = appCtx.packageManager + var pkg = p.optString("package").trim() + val query = p.optString("query").trim() + if (pkg.isBlank() && query.isNotBlank()) { + pkg = resolvePackage(query) ?: return err("Keine App zu '$query' gefunden.") + } + if (pkg.isBlank()) return err("app_launch braucht 'package' ODER 'query' (App-Name).") + val intent = pm.getLaunchIntentForPackage(pkg) + ?: return err("App '$pkg' nicht installiert oder ohne Start-Symbol.") + intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + appCtx.startActivity(intent) + return JSONObject().put("ok", true) + .put("result", JSONObject().put("message", "App '$pkg' gestartet")) + } + + /** Paketname per Label-Teilstring finden (case-insensitive). */ + private fun resolvePackage(query: String): String? { + val pm = appCtx.packageManager + val q = query.lowercase() + val launch = android.content.Intent(android.content.Intent.ACTION_MAIN) + .addCategory(android.content.Intent.CATEGORY_LAUNCHER) + return pm.queryIntentActivities(launch, 0) + .mapNotNull { it.activityInfo } + .firstOrNull { pm.getApplicationLabel(it.applicationInfo).toString().lowercase().contains(q) } + ?.packageName + } + private fun doInfo(): JSONObject { val res = JSONObject() res.put("host", config.displayName()) diff --git a/host-agent/android/app/src/main/res/xml/accessibility_config.xml b/host-agent/android/app/src/main/res/xml/accessibility_config.xml index 8b432c6..5b3c810 100644 --- a/host-agent/android/app/src/main/res/xml/accessibility_config.xml +++ b/host-agent/android/app/src/main/res/xml/accessibility_config.xml @@ -4,5 +4,6 @@ android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds" android:canRetrieveWindowContent="true" + android:canPerformGestures="true" android:notificationTimeout="100" android:description="@string/accessibility_desc" />