Compare commits

...
11 Commits
Author SHA1 Message Date
duffyduck 5b7e205ede release(agent): bump to 0.0.0.7 2026-09-25 13:52:39 +02:00
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
duffyduck 5f5234ee62 release(agent): bump to 0.0.0.3 2026-09-24 21:01:05 +02:00
duffyduckandClaude Opus 4.8 28e58089aa fix(android-agent): fester Signaturschluessel -> Updates statt Paket-Konflikt
Jeder Build signierte bisher mit einem zufaelligen Auto-Debug-Key -> neue APK =
andere Signatur -> Android verweigert Update ('Konflikt mit bestehendem Paket').
Fester signingConfig (rootProject/aria-agent.keystore) fuer debug+release, per
Datei-Existenz-Guard. Keystore liegt nur lokal (gitignored, NICHT im public Repo
— Backup!). Verifiziert: Signer-DN CN=ARIA Host-Agent. Version 0.0.0.3/code 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:59:00 +02:00
15 changed files with 673 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",
"function": {
@@ -2099,6 +2200,11 @@ class Agent:
# Events vom letzten Turn weglassen
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
active_project_id = (project_id or "").strip()
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}"
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_screenshot — via Bridge (/internal/host*) → RVS → Host-Agent."""
host_screenshot / host_ui_* — via Bridge (/internal/host*) → RVS → Host-Agent."""
import base64 as _b64
def _post(path: str, body: dict, timeout: float) -> dict:
@@ -2686,6 +2833,24 @@ class Agent:
+ json.dumps(nodes, ensure_ascii=False, indent=2)
)
# ── Steuern (Meilenstein 3) ──────────────────────────────
_UI_ACTIONS = {
"host_ui_tap": ("ui_tap", ["x", "y"]),
"host_ui_text": ("ui_text", ["x", "y", "text"]),
"host_ui_swipe": ("ui_swipe", ["x1", "y1", "x2", "y2", "duration_ms"]),
"host_ui_key": ("ui_key", ["key"]),
"host_app_launch": ("app_launch", ["package", "query"]),
}
if name in _UI_ACTIONS:
action, keys = _UI_ACTIONS[name]
params = {k: arguments[k] for k in keys if arguments.get(k) is not None}
result = _post("/internal/host",
{"host": host, "action": action, "params": params}, 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
return f"OK ({host}): {r.get('message', 'ausgefuehrt')}"
return f"FEHLER: unbekanntes Host-Tool {name}"
except Exception as exc:
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
@@ -3483,7 +3648,9 @@ class Agent:
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot", "host_ui_dump"):
"host_info", "host_screenshot", "host_ui_dump",
"host_ui_tap", "host_ui_text", "host_ui_swipe",
"host_ui_key", "host_app_launch"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
+67
View File
@@ -3589,6 +3589,17 @@ class ARIABridge:
self._hosts[hid]["last_seen"] = time.time()
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":
iid = (payload.get("instanceId") or "").strip()
if iid:
@@ -4580,6 +4591,17 @@ class ARIABridge:
timeout=float(data.get("timeout") or 60.0),
)
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":
# Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?}
@@ -4929,6 +4951,51 @@ class ARIABridge:
"online": (now - h.get("last_seen", 0)) < 300,
} 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 = "",
params: Optional[dict] = None,
timeout: float = 60.0) -> dict:
+106 -1
View File
@@ -1594,6 +1594,20 @@
</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) -->
<div class="modal-overlay" id="memory-modal">
<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 === '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') {
const p = msg.payload || msg;
const sat = p.satellite || '';
@@ -4643,13 +4670,91 @@
const caps = (h.caps || []).join(', ') || '—';
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;">' +
'<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> ' +
'<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>';
}).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) {
satScanning = id;
renderSatellites();
+22
View File
@@ -1098,6 +1098,10 @@ function connectRVS(forcePlain) {
} else if (msg.type === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
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") {
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
const p = msg.payload || {};
@@ -1944,6 +1948,19 @@ const server = http.createServer((req, res) => {
"Expires": "0",
});
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") {
res.writeHead(200, { "Content-Type": "application/json" });
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,
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
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") {
// Browser will die aktuelle Compute-Flotte.
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
+3
View File
@@ -6,3 +6,6 @@ local.properties
.idea/
*.iml
captures/
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
aria-agent.keystore
+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,
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)
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`).
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten.
3. **Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService. → ARIA bedient Apps (E-Mail-Setup).
4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh`
(Version-Param → Gitea-Release-Asset, wie die App).
3. **✅ Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService (`canPerformGestures`, `dispatchGesture`, `ACTION_SET_TEXT`,
`performGlobalAction`) → Brain-Tools `host_ui_tap`/`_text`/`_swipe`/`_key`/
`host_app_launch`. Tap-Koordinaten = die x/y aus `ui_dump` (echte Pixel), NICHT
aus dem (skalierten) Screenshot. → ARIA bedient Apps (E-Mail-Setup).
4. **Feinschliff** — `app_list`/`notify`, Build-Härtung, `release_agent.sh`.
## Bauen
@@ -190,5 +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
„Releases", nie im Tree.**
> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`),
> `release_agent.sh` vorhanden. Als Nächstes Meilenstein 3 (Steuern).
> Status: **M1–M3 fertig** — verbinden, `info`, `screenshot`, `ui_dump` (sehen)
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
+22 -2
View File
@@ -11,13 +11,33 @@ android {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 2
versionName '0.0.0.2'
versionCode 7
versionName '0.0.0.7'
}
// 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 {
debug {
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
}
release {
minifyEnabled false
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
}
}
compileOptions {
@@ -3,10 +3,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<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" />
<application
@@ -30,7 +34,7 @@
<service
android:name=".AgentService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="dataSync|mediaProjection" />
<receiver
android:name=".BootReceiver"
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
@@ -43,7 +44,19 @@ class AgentService : Service() {
override fun onCreate() {
super.onCreate()
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 {
@@ -52,7 +65,7 @@ class AgentService : Service() {
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
// zuerst startForeground aufrufen (idempotent).
startForeground(NOTIF_ID, buildNotification(status))
startForegroundDataSync(status)
if (intent?.action == ACTION_PROJECTION) {
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@@ -60,10 +73,24 @@ class AgentService : Service() {
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
if (code != 0 && data != null) {
try {
// Android verlangt beim Projizieren einen Vordergrund-Dienst vom
// Typ mediaProjection — VOR getMediaProjection() setzen, sonst
// SecurityException (FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION).
if (Build.VERSION.SDK_INT >= 29) {
startForeground(
NOTIF_ID, buildNotification("Bildschirm-Zugriff aktiv"),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
ScreenCapturer.start(applicationContext, code, data)
updateNotification("$status · Bildschirm-Zugriff aktiv")
if (ScreenCapturer.active) {
updateNotification("$status · Bildschirm-Zugriff aktiv")
} else {
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
} catch (e: Throwable) {
ScreenCapturer.lastError = e.message ?: e.javaClass.simpleName
ScreenCapturer.lastError = "${e.javaClass.simpleName}: ${e.message}"
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
}
@@ -1,11 +1,17 @@
package de.hackersoft.ariaagent
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.os.Bundle
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import org.json.JSONArray
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).
@@ -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 {
@Volatile
var instance: AriaAccessibilityService? = null
@@ -33,7 +33,10 @@ class RvsClient(
@Volatile private var running = false
private var pingThread: Thread? = null
private val caps = listOf("info", "screenshot", "ui_dump") // M3: ui_tap/ui_text/…
private val caps = listOf(
"info", "screenshot", "ui_dump",
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
)
fun start() {
running = true
@@ -126,16 +129,33 @@ class RvsClient(
&& !target.equals(config.displayName(), true)) return
val action = payload.optString("action")
val result: JSONObject = when {
!config.controlEnabled ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo()
action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump()
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
"app_launch", "app_list", "notify") ->
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
else -> err("Aktion '$action' unbekannt.")
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 ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo()
action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump()
// Steuern geht nur bei ENTSPERRTEM Gerät (Gesten/Tasten greifen sonst
// nicht — Android-Sicherheit). Sehen (screenshot/ui_dump) geht gesperrt.
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.")
}
} catch (t: Throwable) {
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
}
result.put("requestId", payload.optString("requestId"))
result.put("hostId", config.hostId())
@@ -143,6 +163,14 @@ class RvsClient(
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)
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
@@ -153,7 +181,7 @@ class RvsClient(
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
}
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 res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
return JSONObject().put("ok", true).put("result", res)
@@ -161,12 +189,75 @@ class RvsClient(
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
private fun doUiDump(): JSONObject {
val svc = AriaAccessibilityService.instance
?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " +
"öffnen' antippen und 'ARIA Host-Agent' einschalten.")
val svc = a11y() ?: return a11yMissing()
return svc.dump()
}
private fun a11y(): AriaAccessibilityService? = AriaAccessibilityService.instance
private fun a11yMissing(): JSONObject =
err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe öffnen' " +
"antippen und 'ARIA Host-Agent' einschalten.")
/** Tippen auf Koordinaten (Pixel wie in ui_dump x/y). */
private fun doUiTap(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
if (!p.has("x") || !p.has("y")) return err("ui_tap braucht x und y (aus ui_dump).")
return svc.tap(p.optInt("x"), p.optInt("y"))
}
/** Text in Feld an (x,y) schreiben. */
private fun doUiText(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
val text = p.optString("text")
if (!p.has("x") || !p.has("y")) return err("ui_text braucht x, y und text.")
return svc.setText(p.optInt("x"), p.optInt("y"), text)
}
/** Wischen/Scrollen von (x1,y1) nach (x2,y2). */
private fun doUiSwipe(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
if (!p.has("x1") || !p.has("y1") || !p.has("x2") || !p.has("y2"))
return err("ui_swipe braucht x1,y1,x2,y2 (optional duration_ms).")
return svc.swipe(p.optInt("x1"), p.optInt("y1"), p.optInt("x2"), p.optInt("y2"),
p.optInt("duration_ms", 300))
}
/** Globale Taste: back/home/recents/notifications. */
private fun doUiKey(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
return svc.globalKey(p.optString("key"))
}
/** App starten (per Paketname oder Namens-Suche). */
private fun doAppLaunch(p: JSONObject): JSONObject {
val pm = appCtx.packageManager
var pkg = p.optString("package").trim()
val query = p.optString("query").trim()
if (pkg.isBlank() && query.isNotBlank()) {
pkg = resolvePackage(query) ?: return err("Keine App zu '$query' gefunden.")
}
if (pkg.isBlank()) return err("app_launch braucht 'package' ODER 'query' (App-Name).")
val intent = pm.getLaunchIntentForPackage(pkg)
?: return err("App '$pkg' nicht installiert oder ohne Start-Symbol.")
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
appCtx.startActivity(intent)
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "App '$pkg' gestartet"))
}
/** Paketname per Label-Teilstring finden (case-insensitive). */
private fun resolvePackage(query: String): String? {
val pm = appCtx.packageManager
val q = query.lowercase()
val launch = android.content.Intent(android.content.Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
return pm.queryIntentActivities(launch, 0)
.mapNotNull { it.activityInfo }
.firstOrNull { pm.getApplicationLabel(it.applicationInfo).toString().lowercase().contains(q) }
?.packageName
}
private fun doInfo(): JSONObject {
val res = JSONObject()
res.put("host", config.displayName())
@@ -23,10 +23,16 @@ import java.io.ByteArrayOutputStream
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
* 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
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
*/
object ScreenCapturer {
private const val MAX_SIDE = 1280
private var projection: MediaProjection? = null
private var reader: ImageReader? = null
private var vdisplay: VirtualDisplay? = null
@@ -56,9 +62,12 @@ object ScreenCapturer {
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
w = metrics.widthPixels
h = metrics.heightPixels
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() }
handler = Handler(handlerThread!!.looper)
@@ -94,50 +103,47 @@ object ScreenCapturer {
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? {
val r = reader ?: return null
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
var image: Image? = null
var tries = 0
while (tries < 20) {
// Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
while (tries < 37) {
image = r.acquireLatestImage()
if (image != null) break
try { Thread.sleep(80) } catch (_: InterruptedException) {}
tries++
}
if (image == null) return null
if (image == null) {
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
return null
}
return try {
val plane = image.planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * w
val padded = Bitmap.createBitmap(
w + (if (pixelStride > 0) rowPadding / pixelStride else 0),
h, Bitmap.Config.ARGB_8888,
)
padded.copyPixelsFromBuffer(buffer)
val cropped = if (rowPadding == 0) padded else Bitmap.createBitmap(padded, 0, 0, w, h)
val scaled = downscale(cropped, 1280)
val bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(buffer)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
if (scaled !== cropped) scaled.recycle()
if (cropped !== padded) cropped.recycle()
padded.recycle()
if (rowPadding == 0) {
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
} else {
val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
cropped.recycle()
}
bmp.recycle()
lastError = null
out.toByteArray()
} catch (_: Exception) {
} catch (t: Throwable) {
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
null
} finally {
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:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:notificationTimeout="100"
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))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.0.2"
AGENT_VERSION = "0.0.0.7"
HEARTBEAT_SEC = 25
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/
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
"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),
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).