Compare commits

..
11 Commits
Author SHA1 Message Date
duffyduck 5941be0f21 release(agent): bump to 0.0.0.6 2026-09-24 21:57:38 +02:00
duffyduckandClaude Opus 4.8 2038b676e6 feat(android-agent): Meilenstein 3 — steuern (tap/text/swipe/key/app_launch)
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 <noreply@anthropic.com>
2026-09-24 21:53:02 +02:00
duffyduck 8b84ee3b9c release(agent): bump to 0.0.0.5 2026-09-24 21:44:05 +02:00
duffyduckandClaude Opus 4.8 c86cd59b5c fix(android-agent): Screenshot — Vordergrund-Dienst-Typ mediaProjection
Geraet (Android 12) meldete beim Projizieren:
'Media projections require a foreground service ...
FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION'. Der Dienst lief als reiner dataSync.
Fix: foregroundServiceType=dataSync|mediaProjection + Permissions
FOREGROUND_SERVICE_MEDIA_PROJECTION/-DATA_SYNC; beim Projektions-Start
startForeground(..., MEDIA_PROJECTION|DATA_SYNC) VOR getMediaProjection().
Normale Starts nutzen explizit nur dataSync (Android-14-konform). Version 0.0.0.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:42:23 +02:00
duffyduck 96012dc986 release(agent): bump to 0.0.0.4 2026-09-24 21:16:56 +02:00
duffyduckandClaude Opus 4.8 80d7f62eaa fix(android-agent): Screenshot-Timeout — nie stumm scheitern + gekappte Aufnahme
Timeout hiess: kein host_result kam zurueck. Ursachen adressiert:
1) handle() umschliesst JEDE Aktion mit try/catch(Throwable) -> auch OOM/Exception
   liefert jetzt ein host_result mit Fehlertext statt Stille (kein Timeout mehr).
2) ScreenCapturer nimmt direkt in gekappter Aufloesung auf (max 1280 lange Seite,
   MediaProjection skaliert) statt Vollbild-Bitmap + Nachskalieren -> viel weniger
   Speicher/Zeit (kein 10-MB-ARGB-Bitmap -> kein OOM), Frame-Warten bis ~3s.
lastError fliesst in die Screenshot-Fehlermeldung. Version 0.0.0.4/code 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:14:15 +02:00
duffyduck 5f5234ee62 release(agent): bump to 0.0.0.3 2026-09-24 21:01:05 +02:00
duffyduckandClaude Opus 4.8 28e58089aa fix(android-agent): fester Signaturschluessel -> Updates statt Paket-Konflikt
Jeder Build signierte bisher mit einem zufaelligen Auto-Debug-Key -> neue APK =
andere Signatur -> Android verweigert Update ('Konflikt mit bestehendem Paket').
Fester signingConfig (rootProject/aria-agent.keystore) fuer debug+release, per
Datei-Existenz-Guard. Keystore liegt nur lokal (gitignored, NICHT im public Repo
— Backup!). Verifiziert: Signer-DN CN=ARIA Host-Agent. Version 0.0.0.3/code 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:59:00 +02:00
duffyduck c1a90102f8 release(agent): bump to 0.0.0.2 2026-09-24 20:52:21 +02:00
duffyduckandClaude Opus 4.8 ba531adc74 fix(android-agent): Crash bei Bildschirm-Zugriff (startForeground-Pflicht)
Der Projection-Consent kommt per startForegroundService, obwohl der Dienst schon
laeuft. Android verlangt danach binnen ~5s ein startForeground() -> fehlte im
Projection-Zweig -> ForegroundServiceDidNotStartInTimeException -> Prozess-Crash
(Diagnostic zeigt den Host bis zum Ping-Timeout noch gruen). Fix: onStartCommand
ruft IMMER zuerst startForeground(). Zusaetzlich ScreenCapturer.start in try/catch
mit lastError, das in der Screenshot-Fehlermeldung und der Notification erscheint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:50:33 +02:00
duffyduck 04d29b256e fix(host-agent): Release-Notes mit echten Zeilenumbruechen (literal \n weg) 2026-09-24 20:43:08 +02:00
12 changed files with 436 additions and 59 deletions
+122 -1
View File
@@ -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", "type": "function",
"function": { "function": {
@@ -2686,6 +2787,24 @@ class Agent:
+ json.dumps(nodes, ensure_ascii=False, indent=2) + 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}" return f"FEHLER: unbekanntes Host-Tool {name}"
except Exception as exc: except Exception as exc:
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}" return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
@@ -3483,7 +3602,9 @@ class Agent:
if name in ("satellite_list", "satellite_devices", "satellite_command"): if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments) return self._dispatch_satellite(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write", 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) return self._dispatch_host(name, arguments)
if name == "vm_register": if name == "vm_register":
pid = (project_id or "").strip() pid = (project_id or "").strip()
+3
View File
@@ -6,3 +6,6 @@ local.properties
.idea/ .idea/
*.iml *.iml
captures/ captures/
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
aria-agent.keystore
+10 -6
View File
@@ -111,10 +111,12 @@ identisch, nur der Wecker ändert sich.
`ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`). `ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`).
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten. ö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 3. **✅ Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService. → ARIA bedient Apps (E-Mail-Setup). AccessibilityService (`canPerformGestures`, `dispatchGesture`, `ACTION_SET_TEXT`,
4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh` `performGlobalAction`) → Brain-Tools `host_ui_tap`/`_text`/`_swipe`/`_key`/
(Version-Param → Gitea-Release-Asset, wie die App). `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 ## 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 einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
„Releases", nie im Tree.** „Releases", nie im Tree.**
> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`), > Status: **M1–M3 fertig** — verbinden, `info`, `screenshot`, `ui_dump` (sehen)
> `release_agent.sh` vorhanden. Als Nächstes Meilenstein 3 (Steuern). > 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).
+22 -2
View File
@@ -11,13 +11,33 @@ android {
applicationId 'de.hackersoft.ariaagent' applicationId 'de.hackersoft.ariaagent'
minSdk 26 minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34 targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 1 versionCode 6
versionName '0.0.0.1' versionName '0.0.0.6'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
// Android neue APKs als Update derselben App akzeptiert (sonst "Konflikt mit
// bestehendem Paket"). Die Keystore-Datei liegt NUR lokal (gitignored, nicht
// im oeffentlichen Repo) — UNBEDINGT sichern, sonst brechen kuenftige Updates.
def ariaKeystore = rootProject.file('aria-agent.keystore')
signingConfigs {
aria {
if (ariaKeystore.exists()) {
storeFile ariaKeystore
storePassword 'ariaagent'
keyAlias 'aria'
keyPassword 'ariaagent'
}
}
} }
buildTypes { buildTypes {
debug {
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
}
release { release {
minifyEnabled false minifyEnabled false
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
} }
} }
compileOptions { compileOptions {
@@ -3,10 +3,14 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<application <application
@@ -30,7 +34,7 @@
<service <service
android:name=".AgentService" android:name=".AgentService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync|mediaProjection" />
<receiver <receiver
android:name=".BootReceiver" android:name=".BootReceiver"
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@@ -43,15 +44,56 @@ class AgentService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createChannel() createChannel()
startForeground(NOTIF_ID, buildNotification("startet …")) startForegroundDataSync("startet …")
}
/** Normaler Vordergrund-Start (Typ dataSync). mediaProjection wird NUR beim
* Projizieren gesetzt — auf Android 14 darf man diesen Typ sonst nicht ohne
* Projection-Token verwenden. */
private fun startForegroundDataSync(text: String) {
if (Build.VERSION.SDK_INT >= 29) {
startForeground(NOTIF_ID, buildNotification(text),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, buildNotification(text))
}
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// WICHTIG: Jeder startForegroundService()-Aufruf MUSS binnen ~5s mit
// startForeground() beantwortet werden — sonst crasht Android den Prozess
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
// zuerst startForeground aufrufen (idempotent).
startForegroundDataSync(status)
if (intent?.action == ACTION_PROJECTION) { if (intent?.action == ACTION_PROJECTION) {
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0) val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA) val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
if (code != 0 && data != null) ScreenCapturer.start(applicationContext, code, data) if (code != 0 && data != null) {
try {
// Android verlangt beim Projizieren einen Vordergrund-Dienst vom
// Typ mediaProjection — VOR getMediaProjection() setzen, sonst
// SecurityException (FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION).
if (Build.VERSION.SDK_INT >= 29) {
startForeground(
NOTIF_ID, buildNotification("Bildschirm-Zugriff aktiv"),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
ScreenCapturer.start(applicationContext, code, data)
if (ScreenCapturer.active) {
updateNotification("$status · Bildschirm-Zugriff aktiv")
} else {
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
} catch (e: Throwable) {
ScreenCapturer.lastError = "${e.javaClass.simpleName}: ${e.message}"
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
}
if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen
return START_STICKY return START_STICKY
} }
@@ -1,11 +1,17 @@
package de.hackersoft.ariaagent package de.hackersoft.ariaagent
import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityNodeInfo
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND). * Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND).
@@ -82,6 +88,85 @@ class AriaAccessibilityService : AccessibilityService() {
} }
} }
// ─── Steuern (Meilenstein 3) ────────────────────────────────────
/** Tippt auf Bildschirm-Koordinaten (Pixel wie in ui_dump x/y). */
fun tap(x: Int, y: Int): JSONObject {
val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) }
return gesture(path, 0, 60, "Tippen ($x,$y)")
}
/** Wischt von (x1,y1) nach (x2,y2) ueber dauerMs (Scrollen/Swipen). */
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int): JSONObject {
val path = Path().apply {
moveTo(x1.toFloat(), y1.toFloat())
lineTo(x2.toFloat(), y2.toFloat())
}
return gesture(path, 0, durationMs.coerceIn(50, 5000).toLong(),
"Wischen ($x1,$y1 -> $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 { companion object {
@Volatile @Volatile
var instance: AriaAccessibilityService? = null var instance: AriaAccessibilityService? = null
@@ -33,7 +33,10 @@ class RvsClient(
@Volatile private var running = false @Volatile private var running = false
private var pingThread: Thread? = null 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() { fun start() {
running = true running = true
@@ -126,17 +129,29 @@ class RvsClient(
&& !target.equals(config.displayName(), true)) return && !target.equals(config.displayName(), true)) return
val action = payload.optString("action") val action = payload.optString("action")
val result: JSONObject = when { 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).
val result: JSONObject = try {
when {
!config.controlEnabled -> !config.controlEnabled ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').") err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo() action == "info" -> doInfo()
action == "screenshot" -> doScreenshot() action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump() action == "ui_dump" -> doUiDump()
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key", action == "ui_tap" -> doUiTap(params)
"app_launch", "app_list", "notify") -> action == "ui_text" -> doUiText(params)
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).") 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.") else -> err("Aktion '$action' unbekannt.")
} }
} catch (t: Throwable) {
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
}
result.put("requestId", payload.optString("requestId")) result.put("requestId", payload.optString("requestId"))
result.put("hostId", config.hostId()) result.put("hostId", config.hostId())
result.put("action", action) result.put("action", action)
@@ -147,11 +162,13 @@ class RvsClient(
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */ /** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
private fun doScreenshot(): JSONObject { private fun doScreenshot(): JSONObject {
if (!ScreenCapturer.active) if (!ScreenCapturer.active) {
val why = ScreenCapturer.lastError?.let { " (letzter Fehler: $it)" } ?: ""
return err("Bildschirm-Zugriff nicht erlaubt. In der Agent-App auf dem Handy " + return err("Bildschirm-Zugriff nicht erlaubt. In der Agent-App auf dem Handy " +
"einmalig 'Bildschirm-Zugriff erlauben' antippen.") "einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
}
val png = ScreenCapturer.capture() val png = ScreenCapturer.capture()
?: return err("Screenshot fehlgeschlagen (kein Frame). Ist der Bildschirm an?") ?: return err("Screenshot fehlgeschlagen: ${ScreenCapturer.lastError ?: "unbekannt"}")
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP) val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64) val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
return JSONObject().put("ok", true).put("result", res) return JSONObject().put("ok", true).put("result", res)
@@ -159,12 +176,75 @@ class RvsClient(
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */ /** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
private fun doUiDump(): JSONObject { private fun doUiDump(): JSONObject {
val svc = AriaAccessibilityService.instance val svc = a11y() ?: return a11yMissing()
?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " +
"öffnen' antippen und 'ARIA Host-Agent' einschalten.")
return svc.dump() 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 { private fun doInfo(): JSONObject {
val res = JSONObject() val res = JSONObject()
res.put("host", config.displayName()) res.put("host", config.displayName())
@@ -23,10 +23,16 @@ import java.io.ByteArrayOutputStream
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()` * Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst. * bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
* *
* Aufgenommen wird direkt in GEKAPPTER Aufloesung (max. 1280 lange Seite): die
* MediaProjection skaliert den Bildschirminhalt auf die VirtualDisplay-Groesse.
* Das haelt Speicher/Zeit klein (kein 10-MB-Vollbild-Bitmap -> kein OOM/Timeout).
*
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden * Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
* (Android-Sicherheit — Projection-Token ist nicht persistierbar). * (Android-Sicherheit — Projection-Token ist nicht persistierbar).
*/ */
object ScreenCapturer { object ScreenCapturer {
private const val MAX_SIDE = 1280
private var projection: MediaProjection? = null private var projection: MediaProjection? = null
private var reader: ImageReader? = null private var reader: ImageReader? = null
private var vdisplay: VirtualDisplay? = null private var vdisplay: VirtualDisplay? = null
@@ -36,6 +42,10 @@ object ScreenCapturer {
private var h = 0 private var h = 0
private var dpi = 0 private var dpi = 0
/** Letzter Init-/Capture-Fehler (fuer die Fehlermeldung an ARIA). */
@Volatile
var lastError: String? = null
val active: Boolean val active: Boolean
@Synchronized get() = projection != null @Synchronized get() = projection != null
@@ -43,31 +53,41 @@ object ScreenCapturer {
fun start(ctx: Context, resultCode: Int, data: Intent) { fun start(ctx: Context, resultCode: Int, data: Intent) {
stop() stop()
val mpm = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val mpm = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val mp = mpm.getMediaProjection(resultCode, data) ?: return val mp = mpm.getMediaProjection(resultCode, data) ?: run {
lastError = "getMediaProjection lieferte null"
return
}
val metrics = DisplayMetrics() val metrics = DisplayMetrics()
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics) wm.defaultDisplay.getRealMetrics(metrics)
w = metrics.widthPixels
h = metrics.heightPixels
dpi = metrics.densityDpi dpi = metrics.densityDpi
// Direkt gekappt aufnehmen (lange Seite <= MAX_SIDE), Seitenverhaeltnis wahren.
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
val scale = if (longSide > MAX_SIDE) MAX_SIDE.toFloat() / longSide else 1f
w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
handlerThread = HandlerThread("aria-capture").also { it.start() } handlerThread = HandlerThread("aria-capture").also { it.start() }
handler = Handler(handlerThread!!.looper) handler = Handler(handlerThread!!.looper)
// Ab Android 14 Pflicht VOR createVirtualDisplay; frueher unschaedlich.
mp.registerCallback(object : MediaProjection.Callback() { mp.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { stop() } override fun onStop() { stop() }
}, handler) }, handler)
val ir = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2) val ir = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
reader = ir reader = ir
// AUTO_MIRROR = Standard-Flag fuer MediaProjection-Capture (die Projection
// selbst autorisiert die Aufnahme, kein Sonderrecht noetig).
vdisplay = mp.createVirtualDisplay( vdisplay = mp.createVirtualDisplay(
"aria-screen", w, h, dpi, "aria-screen", w, h, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
ir.surface, null, handler, ir.surface, null, handler,
) )
projection = mp projection = mp
lastError = null
} }
@Synchronized @Synchronized
@@ -83,50 +103,47 @@ object ScreenCapturer {
handler = null handler = null
} }
/** PNG-Bytes des aktuellen Bildschirms, oder null. Wartet kurz auf einen Frame. */ /** PNG-Bytes des aktuellen Bildschirms, oder null (Grund in lastError). */
fun capture(): ByteArray? { fun capture(): ByteArray? {
val r = reader ?: return null val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
var image: Image? = null var image: Image? = null
var tries = 0 var tries = 0
while (tries < 20) { // Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
while (tries < 37) {
image = r.acquireLatestImage() image = r.acquireLatestImage()
if (image != null) break if (image != null) break
try { Thread.sleep(80) } catch (_: InterruptedException) {} try { Thread.sleep(80) } catch (_: InterruptedException) {}
tries++ tries++
} }
if (image == null) return null if (image == null) {
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
return null
}
return try { return try {
val plane = image.planes[0] val plane = image.planes[0]
val buffer = plane.buffer val buffer = plane.buffer
val pixelStride = plane.pixelStride val pixelStride = plane.pixelStride
val rowStride = plane.rowStride val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * w val rowPadding = rowStride - pixelStride * w
val padded = Bitmap.createBitmap( val bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
w + (if (pixelStride > 0) rowPadding / pixelStride else 0), val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
h, Bitmap.Config.ARGB_8888, bmp.copyPixelsFromBuffer(buffer)
)
padded.copyPixelsFromBuffer(buffer)
val cropped = if (rowPadding == 0) padded else Bitmap.createBitmap(padded, 0, 0, w, h)
val scaled = downscale(cropped, 1280)
val out = ByteArrayOutputStream() val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out) if (rowPadding == 0) {
if (scaled !== cropped) scaled.recycle() bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
if (cropped !== padded) cropped.recycle() } else {
padded.recycle() val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
cropped.recycle()
}
bmp.recycle()
lastError = null
out.toByteArray() out.toByteArray()
} catch (_: Exception) { } catch (t: Throwable) {
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
null null
} finally { } finally {
try { image?.close() } catch (_: Exception) {} try { image?.close() } catch (_: Exception) {}
} }
} }
private fun downscale(src: Bitmap, maxSide: Int): Bitmap {
val longSide = maxOf(src.width, src.height)
if (longSide <= maxSide) return src
val scale = maxSide.toFloat() / longSide
return Bitmap.createScaledBitmap(
src, (src.width * scale).toInt(), (src.height * scale).toInt(), true,
)
}
} }
@@ -4,5 +4,6 @@
android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds" android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true" android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:notificationTimeout="100" android:notificationTimeout="100"
android:description="@string/accessibility_desc" /> android:description="@string/accessibility_desc" />
+1 -1
View File
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024)) FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt). # Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.0.1" AGENT_VERSION = "0.0.0.6"
HEARTBEAT_SEC = 25 HEARTBEAT_SEC = 25
CAPS = ["exec", "read", "write", "info", "screenshot"] CAPS = ["exec", "read", "write", "info", "screenshot"]
+1 -1
View File
@@ -120,7 +120,7 @@ echo -e " ${GREEN}✓${NC} Tag gepusht\n"
# ── Gitea-Release ──────────────────────────────────────────────────── # ── Gitea-Release ────────────────────────────────────────────────────
echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}" echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}"
BODY="ARIA Host-Agent $TAG\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README." BODY=$(printf 'ARIA Host-Agent %s\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README.' "$TAG")
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY") BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY")
RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \ RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
-u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \ -u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \