From 0acf96860e545d9e0c52bc58bf63dabed651c7e0 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 25 Oct 2026 13:19:54 +0100 Subject: [PATCH] feat(host-agent): Auto-Update fuer den Android-Agent ueber den RVS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent fragt beim Verbinden und alle ~6 h per agent_update_check nach; der RVS antwortet mit agent_update_available, wenn in rvs/updates/agent/ eine neuere APK liegt (eigener Ordner — die Cockpit-App bekommt sie so nicht angeboten). Download im Hintergrund per HTTP (/update/agent/latest.apk, Raum-Token im Header, 3 Versuche, Groessen-Check), dann Benachrichtigung "Tippen zum Installieren" + Knopf in der App. Installieren per FileProvider und Android-Installer (REQUEST_INSTALL_PACKAGES; ein Tippen bleibt Pflicht). host_hello meldet jetzt die Version. release_agent.sh kopiert die APK auf den RVS, wenn RVS_UPDATE_HOST gesetzt ist. RVS: APK-Auslieferung als serveApk() fuer beide Apps zusammengefasst. Co-Authored-By: Claude Opus 5.5 --- host-agent/android/README.md | 16 ++ .../android/app/src/main/AndroidManifest.xml | 13 ++ .../de/hackersoft/ariaagent/AgentService.kt | 5 + .../de/hackersoft/ariaagent/AgentUpdater.kt | 145 ++++++++++++++++++ .../de/hackersoft/ariaagent/MainActivity.kt | 17 +- .../java/de/hackersoft/ariaagent/RvsClient.kt | 18 +++ .../app/src/main/res/layout/activity_main.xml | 9 ++ .../app/src/main/res/xml/file_paths.xml | 5 + host-agent/release_agent.sh | 15 ++ rvs/server.js | 107 ++++++++----- 10 files changed, 309 insertions(+), 41 deletions(-) create mode 100644 host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentUpdater.kt create mode 100644 host-agent/android/app/src/main/res/xml/file_paths.xml diff --git a/host-agent/android/README.md b/host-agent/android/README.md index 64b9adc..f2d9ea7 100644 --- a/host-agent/android/README.md +++ b/host-agent/android/README.md @@ -268,3 +268,19 @@ einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter > 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). + +## Auto-Update + +Der Agent fragt beim Verbinden (und danach alle ~6 h) den RVS nach einer neueren +Version (`agent_update_check`). Liegt auf dem RVS eine neuere APK in +`rvs/updates/agent/`, lädt er sie im Hintergrund per HTTP +(`/update/agent/latest.apk`, Token im Header) und zeigt +**„ARIA Host-Agent x.y bereit — Tippen zum Installieren"** — zusätzlich einen +Knopf in der App. Den Install-Dialog zeigt Android selbst; still installieren +darf eine normale App nicht. Beim ersten Mal fragt Android, ob die App +„unbekannte Apps installieren" darf. + +`release_agent.sh` kopiert die APK automatisch auf den RVS, wenn `RVS_UPDATE_HOST` +gesetzt ist (wie bei `release.sh`). Manuell: +`scp aria-android-agent.apk :~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v.apk` +— die Version muss im Dateinamen stehen. diff --git a/host-agent/android/app/src/main/AndroidManifest.xml b/host-agent/android/app/src/main/AndroidManifest.xml index 6165181..9d45bcc 100644 --- a/host-agent/android/app/src/main/AndroidManifest.xml +++ b/host-agent/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + + + + + = 26) ctx.startForegroundService(i) else ctx.startService(i) diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentUpdater.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentUpdater.kt new file mode 100644 index 0000000..8e97ae6 --- /dev/null +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentUpdater.kt @@ -0,0 +1,145 @@ +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 okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Auto-Update fuer den Host-Agent (Android / Fire TV). + * + * 1. RvsClient fragt beim Verbinden (und alle paar Stunden) per + * agent_update_check nach; der RVS antwortet mit agent_update_available. + * 2. Wir laden die APK im Hintergrund per HTTP vom RVS + * (/update/agent/latest.apk, Token im Header) — sie ist nur ein paar MB. + * 3. Dann eine Benachrichtigung "Tippen zum Installieren" (und ein Knopf in + * der App). Den Install-Dialog zeigt Android selbst — still installieren + * darf eine normale App nicht, ein Tippen braucht es immer. + */ +object AgentUpdater { + private const val CH = "aria_agent_update" + private const val NOTIF_ID = 4711 + private const val DIR = "updates" + + /** Fertig geladene, noch nicht installierte Version ("" = keine) */ + @Volatile var readyVersion: String = "" + private set + @Volatile private var downloading = false + + private val http = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) // Abbruch nur, wenn der Download haengt + .build() + + fun installedVersion(ctx: Context): String = try { + ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0" + } catch (_: Exception) { "0" } + + /** a > b ? (Versionen wie 0.0.1.6) */ + fun isNewer(a: String, b: String): Boolean { + val pa = a.split('.').map { it.toIntOrNull() ?: 0 } + val pb = b.split('.').map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(pa.size, pb.size)) { + val d = pa.getOrElse(i) { 0 } - pb.getOrElse(i) { 0 } + if (d != 0) return d > 0 + } + return false + } + + private fun apkFile(ctx: Context, version: String) = + File(File(ctx.cacheDir, DIR).apply { mkdirs() }, "aria-host-agent-v$version.apk") + + /** RVS meldet eine neue Version → im Hintergrund laden, dann melden. */ + fun onAvailable(ctx: Context, config: AgentConfig, version: String, size: Long) { + val app = ctx.applicationContext + if (!isNewer(version, installedVersion(app))) return + val file = apkFile(app, version) + if (readyVersion == version && file.exists()) { notifyReady(app, version); return } + if (downloading) return + downloading = true + Thread { + try { + // Reste frueherer Versionen wegraeumen + File(app.cacheDir, DIR).listFiles()?.forEach { if (it != file) it.delete() } + val proto = if (config.tls) "https" else "http" + val req = Request.Builder() + .url("$proto://${config.host}:${config.port}/update/agent/latest.apk") + .header("X-RVS-Token", config.token) + .build() + var lastErr = "" + for (attempt in 1..3) { + try { + http.newCall(req).execute().use { res -> + if (!res.isSuccessful) throw IllegalStateException("HTTP ${res.code}") + val body = res.body ?: throw IllegalStateException("leere Antwort") + val expected = body.contentLength() + val tmp = File(file.path + ".part") + body.byteStream().use { input -> tmp.outputStream().use { input.copyTo(it) } } + if (expected > 0 && tmp.length() != expected) { + throw IllegalStateException("unvollstaendig (${tmp.length()} von $expected)") + } + if (!tmp.renameTo(file)) throw IllegalStateException("Speichern fehlgeschlagen") + } + readyVersion = version + notifyReady(app, version) + AgentService.broadcastStatus(app) + return@Thread + } catch (e: Exception) { + lastErr = e.message ?: e.toString() + try { Thread.sleep(3000L * attempt) } catch (_: InterruptedException) { return@Thread } + } + } + android.util.Log.w("AgentUpdater", "Update $version nicht geladen: $lastErr") + } finally { + downloading = false + } + }.start() + } + + /** Install-Dialog von Android oeffnen (aus Benachrichtigung oder App-Knopf). */ + fun install(ctx: Context): Boolean { + val v = readyVersion + if (v.isEmpty()) return false + val file = apkFile(ctx, v) + if (!file.exists()) { readyVersion = ""; return false } + // Android 8+: "Unbekannte Apps installieren" muss fuer DIESE App erlaubt sein + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !ctx.packageManager.canRequestPackageInstalls()) { + ctx.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${ctx.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + return true + } + val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file) + ctx.startActivity(Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)) + return true + } + + private fun notifyReady(ctx: Context, version: String) { + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + nm.createNotificationChannel(NotificationChannel(CH, "Updates", NotificationManager.IMPORTANCE_DEFAULT)) + } + val open = PendingIntent.getActivity(ctx, 1, + Intent(ctx, MainActivity::class.java).setAction(MainActivity.ACTION_INSTALL_UPDATE) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + 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 Host-Agent $version bereit") + .setContentText("Tippen zum Installieren") + .setContentIntent(open) + .setAutoCancel(true) + .build()) + } +} diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt index 7deeaa6..0e7a71e 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt @@ -26,6 +26,11 @@ import org.json.JSONObject class MainActivity : AppCompatActivity() { + companion object { + /** Aus der "Update bereit"-Benachrichtigung: gleich den Install-Dialog oeffnen */ + const val ACTION_INSTALL_UPDATE = "de.hackersoft.ariaagent.INSTALL_UPDATE" + } + private lateinit var host: EditText private lateinit var port: EditText private lateinit var token: EditText @@ -78,6 +83,10 @@ class MainActivity : AppCompatActivity() { tls = findViewById(R.id.tls) control = findViewById(R.id.control) statusView = findViewById(R.id.status) + findViewById