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 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 16:05:45 +01:00
co-authored by Claude Opus 5.5
parent 5912e47e32
commit 15fbc8ff9c
6 changed files with 313 additions and 3 deletions
+19
View File
@@ -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
+54 -1
View File
@@ -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()
+37
View File
@@ -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
@@ -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)
}
@@ -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<String, LinkedBlockingQueue<JSONObject>>()
/** 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<JSONObject>(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")
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Auto-Update: nur der Update-Ordner im Cache wird an den Installer freigegeben -->
<!-- Auto-Update: nur Update- und Eingangsordner im Cache werden an den Installer freigegeben -->
<paths>
<cache-path name="updates" path="updates/" />
<!-- Von ARIA geschickte APKs (host_command apk_install) -->
<cache-path name="incoming" path="incoming/" />
</paths>