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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user