Compare commits

...
8 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 47cc1b7f08 feat(android-agent): Sperre-Erkennung — Steuern nur entsperrt (v0.0.0.7)
Erkenntnis aus dem Live-Test: bei gesperrtem Bildschirm greifen Gesten/Tasten
nicht (Android-Sicherheit), der Agent meldete aber still 'ok' -> Verwirrung
(home,home,home, Screenshot zeigt trotzdem alte App). Fix: KeyguardManager.
isKeyguardLocked() gate fuer Steuer-Aktionen (ui_tap/text/swipe/key/app_launch)
-> klare Meldung 'Geraet ist gesperrt - bitte entsperren'. Sehen (screenshot/
ui_dump/info) bleibt auch gesperrt erlaubt. README-Hinweis ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:44:49 +02:00
duffyduckandClaude Opus 4.8 f4b7148cf9 feat(diagnostic): Agent-Historie pro Agent — Akkordeon je Nachricht
Jede Host-Aktion wird als Historie-Eintrag festgehalten und in der Diagnostic
pro User-Nachricht gruppiert aufklappbar dargestellt (mit Screenshot-Thumbnails).

- Brain: trace_id+msg pro chat(); _dispatch_host als Wrapper um _dispatch_host_inner
  emittiert je Aktion einen Eintrag an POST /internal/agent-history (Datei-Pfad aus
  Screenshot-Text erkannt).
- Bridge: /internal/agent-history persistiert nach /shared/config/agent_history/
  <hostId>.jsonl (Ringpuffer 200) + broadcastet agent_history; agent_history_query
  -> agent_history_list.
- RVS: agent_history/_query/_list in ALLOWED_TYPES (sonst still verworfen).
- Diagnostic-Server: relay browser<->RVS + /uploads/<file> Bild-Endpoint (nur
  /shared/uploads, kein Traversal).
- index.html: 'Historie'-Button je Host, Modal mit Akkordeon (Gruppe=Nachricht,
  aufklappbar, Screenshot-Thumbs), Live-Refresh bei agent_history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:32:40 +02:00
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
14 changed files with 650 additions and 59 deletions
+169 -2
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": {
@@ -2099,6 +2200,11 @@ class Agent:
# Events vom letzten Turn weglassen # Events vom letzten Turn weglassen
self._pending_events = [] self._pending_events = []
# Trace fuer die Agent-Historie (Diagnostic gruppiert Host-Aktionen pro
# dieser einen User-Nachricht). Best-effort, nur Anzeige.
self._trace_id = os.urandom(6).hex()
self._trace_msg = user_message[:160]
# Projekt-Kontext pro Request statt aus globalem State # Projekt-Kontext pro Request statt aus globalem State
active_project_id = (project_id or "").strip() active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None active_project = projects_mod.get_project(active_project_id) if active_project_id else None
@@ -2557,8 +2663,49 @@ class Agent:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}" return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str: def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
'was wurde pro Nachricht auf diesem Agent gemacht')."""
text = self._dispatch_host_inner(name, arguments)
try:
self._emit_agent_history(name, arguments, text)
except Exception:
pass
return text
def _emit_agent_history(self, name: str, arguments: dict, text: str) -> None:
"""Best-effort: ein Historie-Eintrag pro Host-Aktion an die Bridge."""
if name == "host_list":
return
host = (arguments.get("host") or "").strip()
if not host:
return
ok = not text.lstrip().upper().startswith("FEHLER")
m = re.search(r"/shared/uploads/\S+?\.(?:png|jpg|jpeg|webp)", text)
body = {
"host": host,
"trace_id": getattr(self, "_trace_id", ""),
"msg": getattr(self, "_trace_msg", ""),
"tool": name,
"action": name[len("host_"):] if name.startswith("host_") else name,
"args": {k: v for k, v in arguments.items() if k != "host"},
"ok": ok,
"summary": (text.strip().splitlines()[0] if text.strip() else "")[:200],
"file": m.group(0) if m else None,
"ts": int(time.time() * 1000),
}
try:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(f"{BRIDGE_URL}/internal/agent-history",
data=data, method="POST",
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=5).close()
except Exception:
pass
def _dispatch_host_inner(self, name: str, arguments: dict) -> str:
"""host_list / host_exec / host_read / host_write / host_info / """host_list / host_exec / host_read / host_write / host_info /
host_screenshot — via Bridge (/internal/host*) → RVS → Host-Agent.""" host_screenshot / host_ui_* — via Bridge (/internal/host*) → RVS → Host-Agent."""
import base64 as _b64 import base64 as _b64
def _post(path: str, body: dict, timeout: float) -> dict: def _post(path: str, body: dict, timeout: float) -> dict:
@@ -2686,6 +2833,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 +3648,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()
+67
View File
@@ -3589,6 +3589,17 @@ class ARIABridge:
self._hosts[hid]["last_seen"] = time.time() self._hosts[hid]["last_seen"] = time.time()
return return
elif msg_type == "agent_history_query":
# Diagnostic fragt die gespeicherte Historie eines Agenten ab.
hid = (payload.get("host") or "").strip()
events = self._agent_history_read(hid) if hid else []
await self._send_to_rvs({
"type": "agent_history_list",
"payload": {"host": hid, "events": events},
"timestamp": int(time.time() * 1000),
})
return
elif msg_type == "worker_hello": elif msg_type == "worker_hello":
iid = (payload.get("instanceId") or "").strip() iid = (payload.get("instanceId") or "").strip()
if iid: if iid:
@@ -4580,6 +4591,17 @@ class ARIABridge:
timeout=float(data.get("timeout") or 60.0), timeout=float(data.get("timeout") or 60.0),
) )
await _send_response(writer, 200, result) await _send_response(writer, 200, result)
elif method == "POST" and path == "/internal/agent-history":
# Brain: ein Historie-Eintrag pro Host-Aktion (fuer die Diagnostic).
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
self._agent_history_append(data)
await self._send_to_rvs({"type": "agent_history", "payload": data,
"timestamp": int(time.time() * 1000)})
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/satellite": elif method == "POST" and path == "/internal/satellite":
# Brain-Tool: Discovery oder Command an einen Satelliten. # Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?} # body: {op:'discover'|'command', satellite, device?, action?, params?}
@@ -4929,6 +4951,51 @@ class ARIABridge:
"online": (now - h.get("last_seen", 0)) < 300, "online": (now - h.get("last_seen", 0)) < 300,
} for h in self._hosts.values()] } for h in self._hosts.values()]
_AGENT_HISTORY_DIR = "/shared/config/agent_history"
_AGENT_HISTORY_MAX = 200
def _agent_history_path(self, host: str) -> str:
safe = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-") or "host"
return os.path.join(self._AGENT_HISTORY_DIR, f"{safe}.jsonl")
def _agent_history_append(self, entry: dict) -> None:
"""Haengt einen Historie-Eintrag an <hostId>.jsonl (Ringpuffer)."""
host = (entry.get("host") or "").strip()
if not host:
return
try:
os.makedirs(self._AGENT_HISTORY_DIR, exist_ok=True)
path = self._agent_history_path(host)
lines = []
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
lines.append(json.dumps(entry, ensure_ascii=False))
lines = lines[-self._AGENT_HISTORY_MAX:]
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
except Exception as exc:
logger.warning("[history] append fehlgeschlagen: %s", exc)
def _agent_history_read(self, host: str, limit: int = 200) -> list:
try:
path = self._agent_history_path(host)
if not os.path.exists(path):
return []
out = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except Exception:
pass
return out[-limit:]
except Exception:
return []
async def _host_request(self, host: str = "", action: str = "", async def _host_request(self, host: str = "", action: str = "",
params: Optional[dict] = None, params: Optional[dict] = None,
timeout: float = 60.0) -> dict: timeout: float = 60.0) -> dict:
+106 -1
View File
@@ -1594,6 +1594,20 @@
</div> </div>
</div> </div>
<!-- Agent-Historie-Modal (Befehle + Screenshots pro Nachricht) -->
<div class="modal-overlay" id="agent-history-modal">
<div class="modal-box" style="max-width:760px;">
<div class="modal-header">
<h3 id="agent-history-title">Historie</h3>
<button class="modal-close" onclick="closeAgentHistory()">&times;</button>
</div>
<div style="padding:6px 16px;color:#8888AA;font-size:11px;">
Jede Zeile = eine Anfrage an ARIA. Aufklappen zeigt, was auf diesem Agenten dafür gemacht wurde.
</div>
<div class="modal-body" id="agent-history-body" style="padding:10px 16px 16px;max-height:70vh;overflow-y:auto;"></div>
</div>
</div>
<!-- Memory-Modal (Neu + Editieren) --> <!-- Memory-Modal (Neu + Editieren) -->
<div class="modal-overlay" id="memory-modal"> <div class="modal-overlay" id="memory-modal">
<div class="modal-box" style="max-width:640px;"> <div class="modal-box" style="max-width:640px;">
@@ -2185,6 +2199,19 @@
} }
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; } if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; } if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
if (msg.type === 'agent_history_list') {
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
renderAgentHistory(msg.payload.events || []);
}
return;
}
if (msg.type === 'agent_history') {
// Live: wenn das Modal fuer diesen Host offen ist, neu abfragen (einfach + robust).
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
send({ action: 'agent_history_query', host: agentHistoryHost });
}
return;
}
if (msg.type === 'sat_creds_list_result') { if (msg.type === 'sat_creds_list_result') {
const p = msg.payload || msg; const p = msg.payload || msg;
const sat = p.satellite || ''; const sat = p.satellite || '';
@@ -4643,13 +4670,91 @@
const caps = (h.caps || []).join(', ') || '—'; const caps = (h.caps || []).join(', ') || '—';
const ctrl = h.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#FF6E6E;">CONTROL_ENABLED=false</span>'; const ctrl = h.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#FF6E6E;">CONTROL_ENABLED=false</span>';
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' + return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' + '<div style="display:flex;align-items:center;gap:8px;">' +
'<div style="flex:1;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' + '<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' +
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(h.hostId) + '</span></div>' + '<span style="color:#666;font-size:11px;">id=' + escapeHtml(h.hostId) + '</span></div>' +
'<button onclick="openAgentHistory(\'' + escapeHtml(h.hostId) + '\')" style="background:#12121E;color:#FFD60A;border:1px solid #FFD60A44;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;">🕘 Historie</button>' +
'</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">' + escapeHtml(h.os || '') + ' · kann: ' + escapeHtml(caps) + ' · ' + ctrl + '</div>' + '<div style="color:#8888AA;font-size:11px;margin-top:4px;">' + escapeHtml(h.os || '') + ' · kann: ' + escapeHtml(caps) + ' · ' + ctrl + '</div>' +
'</div>'; '</div>';
}).join(''); }).join('');
} }
// ── Agent-Historie (Befehle + Screenshots pro Nachricht) ──────────
let agentHistoryHost = null;
function openAgentHistory(hostId) {
const h = (hosts || []).find(x => x.hostId === hostId);
agentHistoryHost = hostId;
document.getElementById('agent-history-title').textContent =
'Historie: ' + ((h && (h.name || h.hostId)) || hostId);
document.getElementById('agent-history-body').innerHTML =
'<span style="color:#8888AA;">lade …</span>';
document.getElementById('agent-history-modal').classList.add('open');
send({ action: 'agent_history_query', host: hostId });
}
function closeAgentHistory() {
document.getElementById('agent-history-modal').classList.remove('open');
agentHistoryHost = null;
}
function toggleHistGroup(gi) {
const el = document.getElementById('hist-group-' + gi);
const caret = document.getElementById('hist-caret-' + gi);
if (!el) return;
const open = el.style.display !== 'none';
el.style.display = open ? 'none' : 'block';
if (caret) caret.textContent = open ? '▶' : '▼';
}
function renderAgentHistory(events) {
const body = document.getElementById('agent-history-body');
if (!body) return;
if (!events || !events.length) {
body.innerHTML = '<span style="color:#8888AA;">Noch keine Aktionen für diesen Agenten aufgezeichnet. ' +
'Sobald ARIA etwas auf ihm tut (Screenshot, tippen, …), erscheint es hier.</span>';
return;
}
// Nach trace_id gruppieren (eine Gruppe = eine User-Nachricht), Reihenfolge erhalten.
const groups = [], idx = {};
events.forEach(ev => {
const key = ev.trace_id || ('_' + ev.ts);
if (!(key in idx)) { idx[key] = groups.length; groups.push({ msg: ev.msg || '', ts: ev.ts, items: [] }); }
groups[idx[key]].items.push(ev);
});
groups.reverse(); // neueste Nachricht oben
body.innerHTML = groups.map((g, gi) => {
const t = new Date(g.ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const bad = g.items.some(e => e.ok === false);
const dot = bad ? '#FF9500' : '#34C759';
const head = '<div onclick="toggleHistGroup(' + gi + ')" style="cursor:pointer;padding:8px 10px;background:#12121E;border-radius:6px;display:flex;align-items:center;gap:8px;">' +
'<span id="hist-caret-' + gi + '" style="color:#8888AA;">' + (gi === 0 ? '▼' : '▶') + '</span>' +
'<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';"></span>' +
'<span style="color:#666;font-size:11px;">' + t + '</span>' +
'<span style="color:#E0E0F0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(g.msg || '(ohne Text)') + '</span>' +
'<span style="color:#8888AA;font-size:11px;white-space:nowrap;">' + g.items.length + ' Aktion(en)</span></div>';
const rows = g.items.map(e => {
const good = e.ok !== false;
const ic = good ? '✓' : '✗';
const icColor = good ? '#34C759' : '#FF6E6E';
const args = (e.args && Object.keys(e.args).length)
? ' <span style="color:#666;font-size:11px;">' + escapeHtml(JSON.stringify(e.args)) + '</span>' : '';
let thumb = '';
if (e.file) {
const base = String(e.file).split('/').pop();
thumb = '<div style="margin-top:5px;"><img src="/uploads/' + encodeURIComponent(base) +
'" loading="lazy" style="max-width:180px;max-height:320px;border:1px solid #1E1E2E;border-radius:6px;cursor:pointer;" ' +
'onclick="window.open(this.src)" onerror="this.style.display=\'none\'"></div>';
}
return '<div style="padding:6px 10px 6px 26px;border-bottom:1px solid #15151F;">' +
'<span style="color:' + icColor + ';">' + ic + '</span> ' +
'<code style="color:#FFD60A;">' + escapeHtml(e.action || e.tool || '?') + '</code>' + args +
(e.summary ? '<div style="color:#8888AA;font-size:11px;margin-top:2px;">' + escapeHtml(e.summary) + '</div>' : '') +
thumb + '</div>';
}).join('');
return '<div style="margin-bottom:6px;">' + head +
'<div id="hist-group-' + gi + '" style="display:' + (gi === 0 ? 'block' : 'none') + ';">' + rows + '</div></div>';
}).join('');
}
function scanSatellite(id) { function scanSatellite(id) {
satScanning = id; satScanning = id;
renderSatellites(); renderSatellites();
+22
View File
@@ -1098,6 +1098,10 @@ function connectRVS(forcePlain) {
} else if (msg.type === "rooms_info") { } else if (msg.type === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose). // Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
broadcast({ type: "rooms_info", payload: msg.payload || {} }); broadcast({ type: "rooms_info", payload: msg.payload || {} });
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
// der Bridge → an den Browser (Agent-Historie).
broadcast({ type: msg.type, payload: msg.payload || {} });
} else if (msg.type === "sat_devices") { } else if (msg.type === "sat_devices") {
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser. // Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
const p = msg.payload || {}; const p = msg.payload || {};
@@ -1944,6 +1948,19 @@ const server = http.createServer((req, res) => {
"Expires": "0", "Expires": "0",
}); });
res.end(fs.readFileSync(htmlPath, "utf-8")); res.end(fs.readFileSync(htmlPath, "utf-8"));
} else if (req.url.startsWith("/uploads/")) {
// Screenshots aus der Agent-Historie (nur /shared/uploads, kein Traversal).
const base = path.basename(decodeURIComponent(req.url.slice("/uploads/".length)));
const file = path.join("/shared/uploads", base);
if (!file.startsWith("/shared/uploads/") || !fs.existsSync(file)) {
res.writeHead(404); res.end("not found"); return;
}
const ext = path.extname(file).toLowerCase();
const ct = ext === ".png" ? "image/png"
: (ext === ".jpg" || ext === ".jpeg") ? "image/jpeg"
: ext === ".webp" ? "image/webp" : "application/octet-stream";
res.writeHead(200, { "Content-Type": ct, "Cache-Control": "max-age=86400" });
res.end(fs.readFileSync(file));
} else if (req.url === "/api/state") { } else if (req.url === "/api/state") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ state, logs: logs.slice(-100) })); res.end(JSON.stringify({ state, logs: logs.slice(-100) }));
@@ -2796,6 +2813,11 @@ wss.on("connection", (ws) => {
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen, // Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck. // die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() }); sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() });
} else if (msg.action === "agent_history_query") {
// Browser will die gespeicherte Historie eines Agenten — die Bridge
// antwortet mit agent_history_list (auf derselben rvsWs).
sendToRVS_raw({ type: "agent_history_query",
payload: { host: String(msg.host || "") }, timestamp: Date.now() });
} else if (msg.action === "worker_list") { } else if (msg.action === "worker_list") {
// Browser will die aktuelle Compute-Flotte. // Browser will die aktuelle Compute-Flotte.
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() })); ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
+17 -6
View File
@@ -46,6 +46,13 @@ ARIA-Flow „E-Mail einrichten": `app_launch` (Mail-App) → `screenshot`/`ui_du
(sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen, (sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen,
bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI. bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI.
> **⚠️ Gerät muss ENTSPERRT sein, damit ARIA es steuern kann.** Gesten und
> Systemtasten (`ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch`) greifen bei
> gesperrtem Bildschirm nicht — Android-Sicherheit. Der Agent erkennt das und
> meldet „Gerät ist gesperrt — bitte erst entsperren", statt still „ok" zu sagen.
> **Sehen** (`screenshot`, `ui_dump`, `info`) funktioniert dagegen auch bei
> gesperrtem Gerät.
## Dauerbetrieb — Foreground-Service vs. Push (FCM) ## Dauerbetrieb — Foreground-Service vs. Push (FCM)
Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse
@@ -111,10 +118,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 +199,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).
+2 -2
View File
@@ -11,8 +11,8 @@ 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 3 versionCode 7
versionName '0.0.0.3' versionName '0.0.0.7'
} }
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit // Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
@@ -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}")
} }
} }
@@ -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,23 +129,48 @@ 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", // Steuern geht nur bei ENTSPERRTEM Gerät (Gesten/Tasten greifen sonst
"app_launch", "app_list", "notify") -> // nicht — Android-Sicherheit). Sehen (screenshot/ui_dump) geht gesperrt.
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).") action in CONTROL_ACTIONS && isLocked() ->
err("Gerät ist gesperrt — zum Steuern bitte erst entsperren. " +
"(Screenshot/Bildschirm lesen geht auch gesperrt.)")
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.") 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)
send(webSocket, "host_result", result) send(webSocket, "host_result", result)
} }
/** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */
private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
private fun isLocked(): Boolean = try {
val km = appCtx.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
km.isKeyguardLocked
} catch (_: Exception) { false }
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m) private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */ /** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
@@ -153,7 +181,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 +189,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" />
+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.3" AGENT_VERSION = "0.0.0.6"
HEARTBEAT_SEC = 25 HEARTBEAT_SEC = 25
CAPS = ["exec", "read", "write", "info", "screenshot"] CAPS = ["exec", "read", "write", "info", "screenshot"]
+5
View File
@@ -88,6 +88,11 @@ const ALLOWED_TYPES = new Set([
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/ // host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT. // screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
"host_hello", "host_ping", "host_command", "host_result", "host_hello", "host_ping", "host_command", "host_result",
// Agent-Historie: Brain schickt pro Host-Aktion ein agent_history-Event (via
// Bridge), Diagnostic fragt die gespeicherte Historie per agent_history_query
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
// der RVS sie still an der Allow-List und die Historie bleibt leer.
"agent_history", "agent_history_query", "agent_history_list",
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query), // Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je // RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log). // Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).