Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5941be0f21 | ||
|
|
2038b676e6 | ||
|
|
8b84ee3b9c | ||
|
|
c86cd59b5c | ||
|
|
96012dc986 | ||
|
|
80d7f62eaa | ||
|
|
5f5234ee62 | ||
|
|
28e58089aa |
+122
-1
@@ -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()
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ local.properties
|
|||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
captures/
|
captures/
|
||||||
|
|
||||||
|
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
|
||||||
|
aria-agent.keystore
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 2
|
versionCode 6
|
||||||
versionName '0.0.0.2'
|
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,7 +44,19 @@ 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 {
|
||||||
@@ -52,7 +65,7 @@ class AgentService : Service() {
|
|||||||
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
|
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
|
||||||
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
|
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
|
||||||
// zuerst startForeground aufrufen (idempotent).
|
// zuerst startForeground aufrufen (idempotent).
|
||||||
startForeground(NOTIF_ID, buildNotification(status))
|
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)
|
||||||
@@ -60,10 +73,24 @@ class AgentService : Service() {
|
|||||||
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
|
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
|
||||||
if (code != 0 && data != null) {
|
if (code != 0 && data != null) {
|
||||||
try {
|
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)
|
ScreenCapturer.start(applicationContext, code, data)
|
||||||
|
if (ScreenCapturer.active) {
|
||||||
updateNotification("$status · Bildschirm-Zugriff aktiv")
|
updateNotification("$status · Bildschirm-Zugriff aktiv")
|
||||||
|
} else {
|
||||||
|
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||||
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
ScreenCapturer.lastError = e.message ?: e.javaClass.simpleName
|
ScreenCapturer.lastError = "${e.javaClass.simpleName}: ${e.message}"
|
||||||
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+85
@@ -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)
|
||||||
@@ -153,7 +168,7 @@ class RvsClient(
|
|||||||
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
|
"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)
|
||||||
@@ -161,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
|
||||||
@@ -56,9 +62,12 @@ object ScreenCapturer {
|
|||||||
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)
|
||||||
@@ -94,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" />
|
||||||
|
|||||||
@@ -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.2"
|
AGENT_VERSION = "0.0.0.6"
|
||||||
|
|
||||||
HEARTBEAT_SEC = 25
|
HEARTBEAT_SEC = 25
|
||||||
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
||||||
|
|||||||
Reference in New Issue
Block a user