From 15fbc8ff9c0afaf29b93094486ffe7a63c1b4d3d Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 15 Nov 2026 16:05:45 +0100 Subject: [PATCH] feat(android-agent): APK von ARIA empfangen und installieren Neues Brain-Tool host_install_apk: ARIA schickt eine APK unter /shared/ an einen Android-Agenten, der Installationsdialog von Android oeffnet sich. - Bridge: file_request kann mit offset/length einzelne Stuecke liefern (max. 8 MB); der bisherige Abruf am Stueck bleibt unveraendert - Agent: holt die APK in 4-MB-Stuecken (gezielte file_response vom RVS, nicht an alle im Raum), prueft sha256, oeffnet den Dialog direkt oder per Benachrichtigung; prueft "Unbekannte Apps installieren" vor dem Download - Still installieren geht nicht (Android-Regel), Bestaetigung am Geraet noetig - README: Abschnitt beim Android-Agenten Co-Authored-By: Claude Opus 5.5 --- README.md | 19 +++ aria-brain/agent.py | 55 ++++++- bridge/aria_bridge.py | 37 +++++ .../de/hackersoft/ariaagent/ApkReceiver.kt | 149 ++++++++++++++++++ .../java/de/hackersoft/ariaagent/RvsClient.kt | 52 +++++- .../app/src/main/res/xml/file_paths.xml | 4 +- 6 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ApkReceiver.kt diff --git a/README.md b/README.md index 4fc8f08..d2c58f4 100644 --- a/README.md +++ b/README.md @@ -1232,6 +1232,25 @@ streamt das Mikrofon mit demselben Protokoll wie die App > dieselbe Antwort gleichzeitig vorlesen. Jedes Geraet gibt darum nur aus, was > aus *seiner* eigenen Frage entstanden ist (`audioRequestId` → `messageId`). +**APK installieren** (`host_install_apk`) — ARIA kann eine APK, die unter +`/shared/` liegt, auf das Geraet schicken und den Installationsdialog oeffnen: + +1. Der Agent holt die Datei **stueckweise** (4 MB) per `file_request` mit + `offset`/`length` bei der Bridge ab. Der RVS schickt jede Antwort gezielt nur + an den Agenten — nicht an alle Geraete im Raum —, und der Fire TV muss nie + die ganze Datei auf einmal im Speicher halten. +2. Die Pruefsumme (sha256, vom Brain mitgegeben) wird verglichen; eine + beschaedigte Datei wird verworfen. +3. Android zeigt den Installationsdialog — direkt, wenn der Agent „Ueber + anderen Apps anzeigen" darf, sonst ueber eine Benachrichtigung „Tippen zum + Installieren". + +> **Still installieren geht nicht.** Eine normale App darf das unter Android +> nicht; am Geraet muss jemand „Installieren" bestaetigen. Beim ersten Mal muss +> dort ausserdem **„Unbekannte Apps installieren"** fuer den ARIA Host-Agent +> erlaubt werden — fehlt das, sagt der Agent es ARIA, bevor er etwas laedt. +> Voraussetzung ist ausserdem „Steuerung erlauben" in der Agent-App. + ### Bauen + ausrollen ```bash diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 648119b..e83551a 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1838,6 +1838,31 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "host_install_apk", + "description": ( + "Installiert eine APK auf einem Android-Agenten (Handy, Tablet, " + "Fire TV). Die APK muss unter /shared/ liegen; der Agent holt sie " + "ueber den RVS und oeffnet den Installationsdialog von Android. " + "STILL installieren geht nicht: am Geraet muss jemand 'Installieren' " + "bestaetigen (Android-Regel). Beim ersten Mal muss dort ausserdem " + "'Unbekannte Apps installieren' fuer den ARIA Host-Agent erlaubt " + "werden. Ob die Installation durch ist, pruefst Du danach z.B. mit " + "host_app_launch oder einem Screenshot. Braucht 'Steuerung erlauben' " + "am Geraet." + ), + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "Host-ID/Name (siehe host_list)."}, + "path": {"type": "string", "description": "Pfad der APK unter /shared/, z.B. /shared/uploads/app.apk."}, + }, + "required": ["host", "path"], + }, + }, + }, { "type": "function", "function": { @@ -3843,6 +3868,33 @@ class Agent: + json.dumps(nodes, ensure_ascii=False, indent=2) ) + if name == "host_install_apk": + apk = os.path.realpath((arguments.get("path") or "").strip()) + if not apk.startswith("/shared/") or not apk.lower().endswith(".apk"): + return "FEHLER: path muss eine .apk unter /shared/ sein." + if not os.path.isfile(apk): + return f"FEHLER: APK nicht gefunden: {apk}" + import hashlib as _hl + size = os.path.getsize(apk) + h = _hl.sha256() + with open(apk, "rb") as _f: + for _blk in iter(lambda: _f.read(1024 * 1024), b""): + h.update(_blk) + # Der Agent holt die Datei stueckweise — Zeit nach Groesse + # bemessen (grob 3 s pro MB, mindestens 2 Minuten). + _timeout = min(1800, 120 + 3 * (size // (1024 * 1024))) + result = _post("/internal/host", + {"host": host, "action": "apk_install", + "params": {"path": apk, "size": size, + "sha256": h.hexdigest(), + "name": os.path.basename(apk)}, + "timeout": _timeout}, + _timeout + 10) + if not result.get("ok"): + return f"FEHLER: {result.get('error')}" + r = result.get("result") or {} + return f"OK ({host}): {r.get('message', 'Installation angestossen')}" + # ── Steuern (Meilenstein 3) ────────────────────────────── _UI_ACTIONS = { "host_ui_tap": ("ui_tap", ["x", "y", "button", "double"]), @@ -4860,7 +4912,8 @@ class Agent: "host_info", "host_screenshot", "host_ui_dump", "host_ui_tap", "host_ui_text", "host_ui_swipe", "host_ui_key", "host_app_launch", - "host_notify", "host_speak", "host_volume"): + "host_notify", "host_speak", "host_volume", + "host_install_apk"): return self._dispatch_host(name, arguments) if name == "vm_register": pid = (project_id or "").strip() diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index f0321fe..2fb2991 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -3315,6 +3315,43 @@ class ARIABridge: "timestamp": int(asyncio.get_event_loop().time() * 1000), }) return + # Stueckweiser Abruf (z.B. Android-Agent holt eine APK): mit + # "offset" kommt nur ein Teil zurueck. So muss niemand eine grosse + # Datei am Stueck in den Speicher nehmen (Fire TV hat wenig RAM). + if "offset" in payload: + CHUNK_MAX = 8 * 1024 * 1024 + try: + offset = max(0, int(payload.get("offset") or 0)) + length = int(payload.get("length") or CHUNK_MAX) + except (TypeError, ValueError): + offset, length = 0, CHUNK_MAX + length = max(1, min(length, CHUNK_MAX)) + try: + total = os.path.getsize(server_path) + with open(server_path, "rb") as f: + f.seek(offset) + chunk = f.read(length) + except OSError as exc: + await self._send_to_rvs({ + "type": "file_response", + "payload": {"requestId": req_id, "error": f"Lesen fehlgeschlagen: {exc}"}, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + return + await self._send_to_rvs({ + "type": "file_response", + "payload": { + "requestId": req_id, + "serverPath": server_path, + "name": os.path.basename(server_path), + "offset": offset, + "sizeBytes": total, + "eof": offset + len(chunk) >= total, + "base64": base64.b64encode(chunk).decode("ascii"), + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + return # Groessen-Check VOR base64-Encode + Send. Sonst zerreisst's bei # grossen Files (>~70 MB binaer) die WebSocket-Verbindung mit # Code 1009 (message too big) — RVS-Server droppt, Bridge crasht diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ApkReceiver.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ApkReceiver.kt new file mode 100644 index 0000000..e335878 --- /dev/null +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ApkReceiver.kt @@ -0,0 +1,149 @@ +package de.hackersoft.ariaagent + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest + +/** + * APK von ARIA empfangen und installieren (host_command "apk_install"). + * + * 1. Die APK liegt bei ARIA unter /shared/. Wir holen sie STUECKWEISE per + * file_request (offset/length) — der RVS schickt die Antwort gezielt nur + * an uns, nicht an alle Geraete im Raum, und der Fire TV muss nie die + * ganze Datei auf einmal im Speicher halten. + * 2. Pruefsumme (sha256) vergleichen. + * 3. Installationsdialog von Android oeffnen. Still installieren darf eine + * normale App nicht — am Geraet muss jemand bestaetigen. + */ +object ApkReceiver { + private const val DIR = "incoming" + private const val CHUNK = 4 * 1024 * 1024 + private const val CH = "aria_agent_install" + private const val NOTIF_ID = 4712 + + /** Holt ein Stueck: (path, offset, length) -> file_response-Payload oder null (Timeout). */ + fun interface ChunkFetcher { + fun fetch(path: String, offset: Long, length: Int): JSONObject? + } + + fun run(ctx: Context, params: JSONObject, fetcher: ChunkFetcher): JSONObject { + val path = params.optString("path") + val size = params.optLong("size", -1) + val sha = params.optString("sha256").lowercase() + val name = params.optString("name").ifBlank { File(path).name } + if (!path.startsWith("/shared/") || !path.lowercase().endsWith(".apk")) + return err("path muss eine .apk unter /shared/ sein") + if (size <= 0) return err("Groesse fehlt") + + // Ohne diese Erlaubnis oeffnet Android den Dialog gar nicht — lieber + // VOR dem Download sagen, statt Megabytes umsonst zu holen. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !ctx.packageManager.canRequestPackageInstalls()) { + openUnknownSourcesSettings(ctx) + return err("Auf dem Geraet ist 'Unbekannte Apps installieren' fuer den ARIA Host-Agent " + + "nicht erlaubt. Die Einstellung ist jetzt dort geoeffnet (falls die App im " + + "Vordergrund ist) — einmal erlauben, dann nochmal versuchen.") + } + + val dir = File(ctx.cacheDir, DIR).apply { mkdirs() } + dir.listFiles()?.forEach { it.delete() } // Reste frueherer Uebertragungen + if (dir.usableSpace < size + 20L * 1024 * 1024) + return err("Zu wenig Speicher auf dem Geraet (${size / 1048576} MB noetig, " + + "${dir.usableSpace / 1048576} MB frei)") + + val safe = name.replace(Regex("[^A-Za-z0-9._-]+"), "_") + val file = File(dir, safe) + val md = MessageDigest.getInstance("SHA-256") + file.outputStream().use { out -> + var offset = 0L + while (offset < size) { + var chunk: JSONObject? = null + for (attempt in 1..3) { + chunk = fetcher.fetch(path, offset, CHUNK) + if (chunk != null) break + } + if (chunk == null) return err("Keine Antwort beim Abholen (bei ${offset / 1048576} MB)") + if (chunk.has("error")) return err("Abholen fehlgeschlagen: ${chunk.optString("error")}") + if (!chunk.has("offset")) + return err("Die Bridge kennt den stueckweisen Abruf noch nicht — Bridge neu deployen.") + val bytes = android.util.Base64.decode(chunk.optString("base64"), android.util.Base64.DEFAULT) + if (bytes.isEmpty()) return err("Leeres Stueck bei ${offset / 1048576} MB") + out.write(bytes) + md.update(bytes) + offset += bytes.size + if (chunk.optBoolean("eof")) break + } + } + if (file.length() != size) { + file.delete() + return err("Unvollstaendig: ${file.length()} von $size Bytes") + } + val got = md.digest().joinToString("") { "%02x".format(it) } + if (sha.isNotEmpty() && got != sha) { + file.delete() + return err("Pruefsumme stimmt nicht — Datei beschaedigt, nicht installiert") + } + + val info = try { ctx.packageManager.getPackageArchiveInfo(file.path, 0) } catch (_: Exception) { null } + ?: run { file.delete(); return err("Die Datei ist keine gueltige APK") } + val label = "${info.packageName} ${info.versionName ?: ""}".trim() + + val shown = openInstaller(ctx, file, label) + val msg = if (shown) + "APK $name ($label) uebertragen — der Installationsdialog ist auf dem Geraet offen " + + "und muss dort bestaetigt werden." + else + "APK $name ($label) uebertragen — auf dem Geraet liegt eine Benachrichtigung " + + "'Tippen zum Installieren'. Jemand muss sie antippen und bestaetigen." + return JSONObject().put("ok", true).put("result", JSONObject() + .put("message", msg).put("package", info.packageName) + .put("version", info.versionName ?: "").put("bytes", size)) + } + + /** Dialog direkt oeffnen, wenn Android das aus dem Hintergrund erlaubt; immer + * auch eine Benachrichtigung als Rueckfallweg. true = Dialog direkt geoeffnet. */ + private fun openInstaller(ctx: Context, file: File, label: String): Boolean { + val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + nm.createNotificationChannel(NotificationChannel(CH, "Installationen von ARIA", + NotificationManager.IMPORTANCE_HIGH)) + } + val pi = PendingIntent.getActivity(ctx, 2, intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + nm.notify(NOTIF_ID, NotificationCompat.Builder(ctx, CH) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle("ARIA moechte eine App installieren") + .setContentText("$label — tippen zum Installieren") + .setContentIntent(pi) + .setAutoCancel(true) + .build()) + + // Aus dem Hintergrund duerfen Apps nur mit "Ueber anderen Apps anzeigen" + // eine Activity starten — die hat der Agent meist (Einblendungen). + if (!OverlayView.canDraw(ctx)) return false + return try { ctx.startActivity(intent); true } catch (_: Exception) { false } + } + + private fun openUnknownSourcesSettings(ctx: Context) { + try { + ctx.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${ctx.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: Exception) {} + } + + private fun err(m: String) = JSONObject().put("ok", false).put("error", m) +} diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt index a9555e3..15d4b59 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt @@ -10,6 +10,8 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import org.json.JSONArray import org.json.JSONObject +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit /** @@ -57,7 +59,9 @@ class RvsClient( // Ausgabe auf dem Geraet: Einblendung ueber dem Bild + Sprachausgabe. "notify", "speak", "volume", // Sprachdialog: Mikro oeffnen/schliessen (ARIA kann es auch anstossen). - "listen", "listen_stop") + "listen", "listen_stop", + // APK von ARIA empfangen + Installationsdialog oeffnen. + "apk_install") if (isTv) base += "tv" // Kennzeichen: dieses Geraet ist ein Fernseher/Stick base } @@ -189,6 +193,13 @@ class RvsClient( AgentUpdater.onAvailable(appCtx, config, p.optString("version"), p.optLong("size")) return } + // Antwort auf einen eigenen file_request (APK-Abruf)? Dann an den + // wartenden Thread weiterreichen — sonst niemanden interessiert. + if (type == "file_response") { + val p = msg.optJSONObject("payload") ?: return + fileWaiters[p.optString("requestId")]?.offer(p) + return + } if (type != "host_command") { if (type in VOICE_TYPES) { try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) } @@ -230,6 +241,26 @@ class RvsClient( return } + // APK holen dauert (viele Stuecke): eigener Thread, denn die Antworten + // kommen ueber genau diesen Lese-Thread herein. + if (action == "apk_install") { + if (!config.controlEnabled) { + reply(err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")) + return + } + Thread { + val r = try { + ApkReceiver.run(appCtx, params) { path, offset, length -> + fetchChunk(webSocket, path, offset, length) + } + } catch (t: Throwable) { + err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}") + } + reply(r) + }.also { it.isDaemon = true }.start() + return + } + // 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). @@ -265,6 +296,25 @@ class RvsClient( reply(result) } + /** Wartende file_request-Abrufe: requestId -> Briefkasten fuer die Antwort. */ + private val fileWaiters = ConcurrentHashMap>() + + /** Ein Stueck einer Datei unter /shared/ bei der Bridge abholen (null = Timeout). */ + private fun fetchChunk(webSocket: WebSocket, path: String, offset: Long, length: Int): JSONObject? { + val rid = "apk-" + java.util.UUID.randomUUID().toString() + val box = LinkedBlockingQueue(1) + fileWaiters[rid] = box + try { + val sent = send(webSocket, "file_request", JSONObject() + .put("requestId", rid).put("serverPath", path) + .put("offset", offset).put("length", length)) + if (!sent) return null + return box.poll(60, TimeUnit.SECONDS) + } finally { + fileWaiters.remove(rid) + } + } + /** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */ private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch") diff --git a/host-agent/android/app/src/main/res/xml/file_paths.xml b/host-agent/android/app/src/main/res/xml/file_paths.xml index 47ec25c..7bb91e6 100644 --- a/host-agent/android/app/src/main/res/xml/file_paths.xml +++ b/host-agent/android/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,7 @@ - + + +