feat(host-agent): Auto-Update fuer den Android-Agent ueber den RVS
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <rvs-host>:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v<version>.apk`
|
||||
— die Version muss im Dateinamen stehen.
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<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" />
|
||||
<!-- Auto-Update: neue Agent-APK vom RVS installieren (Android fragt trotzdem nach) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
|
||||
@@ -60,6 +62,17 @@
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- Auto-Update: geladene APK an den Android-Installer uebergeben -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".AgentService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -32,6 +32,11 @@ class AgentService : Service() {
|
||||
@Volatile var status: String = "gestoppt"
|
||||
@Volatile var connected: Boolean = false
|
||||
|
||||
/** App-Oberflaeche auffrischen (z.B. "Update bereit"-Knopf) */
|
||||
fun broadcastStatus(ctx: Context) {
|
||||
ctx.sendBroadcast(Intent(ACTION_STATUS).setPackage(ctx.packageName))
|
||||
}
|
||||
|
||||
fun start(ctx: Context) {
|
||||
val i = Intent(ctx, AgentService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<Button>(R.id.btnUpdate).setOnClickListener {
|
||||
if (!AgentUpdater.install(this)) toast("Kein Update bereit")
|
||||
}
|
||||
if (intent?.action == ACTION_INSTALL_UPDATE) AgentUpdater.install(this)
|
||||
|
||||
findViewById<Button>(R.id.btnScan).setOnClickListener {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
@@ -209,13 +218,19 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun refreshStatus() {
|
||||
val dot = if (AgentService.connected) "🟢" else "🔴"
|
||||
statusView.text = "Status: ${AgentService.status} $dot"
|
||||
statusView.text = "Status: ${AgentService.status} $dot · v${AgentUpdater.installedVersion(this)}"
|
||||
val ready = AgentUpdater.readyVersion
|
||||
findViewById<Button>(R.id.btnUpdate).apply {
|
||||
visibility = if (ready.isNotEmpty()) android.view.View.VISIBLE else android.view.View.GONE
|
||||
text = "Update auf $ready installieren"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent == null) return
|
||||
setIntent(intent)
|
||||
if (intent.action == ACTION_INSTALL_UPDATE) { AgentUpdater.install(this); return }
|
||||
applyProvisioning(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ class RvsClient(
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
onStatus(true, "verbunden")
|
||||
sendHello(webSocket)
|
||||
sendUpdateCheck(webSocket)
|
||||
startPing(webSocket)
|
||||
}
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
@@ -141,9 +142,17 @@ class RvsClient(
|
||||
localIp()?.let { p.put("ip", it) }
|
||||
p.put("caps", JSONArray(caps))
|
||||
p.put("control", config.controlEnabled)
|
||||
p.put("version", AgentUpdater.installedVersion(appCtx))
|
||||
send(webSocket, "host_hello", p)
|
||||
}
|
||||
|
||||
/** Beim RVS nach einer neueren Agent-Version fragen (Antwort: agent_update_available) */
|
||||
private fun sendUpdateCheck(webSocket: WebSocket) {
|
||||
send(webSocket, "agent_update_check", JSONObject()
|
||||
.put("version", AgentUpdater.installedVersion(appCtx))
|
||||
.put("platform", "android"))
|
||||
}
|
||||
|
||||
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
|
||||
private fun localIp(): String? = try {
|
||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||
@@ -153,6 +162,8 @@ class RvsClient(
|
||||
?.hostAddress
|
||||
} catch (_: Throwable) { null }
|
||||
|
||||
private var pingCount = 0
|
||||
|
||||
private fun startPing(webSocket: WebSocket) {
|
||||
pingThread?.interrupt()
|
||||
pingThread = Thread {
|
||||
@@ -161,6 +172,8 @@ class RvsClient(
|
||||
val p = JSONObject().put("hostId", config.hostId())
|
||||
send(webSocket, "host_ping", p)
|
||||
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
||||
// Alle ~6 h nach Updates fragen (25 s × 864)
|
||||
if (++pingCount % 864 == 0) sendUpdateCheck(webSocket)
|
||||
}
|
||||
}.also { it.start() }
|
||||
}
|
||||
@@ -171,6 +184,11 @@ class RvsClient(
|
||||
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
|
||||
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
|
||||
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
|
||||
if (type == "agent_update_available") {
|
||||
val p = msg.optJSONObject("payload") ?: JSONObject()
|
||||
AgentUpdater.onAvailable(appCtx, config, p.optString("version"), p.optLong("size"))
|
||||
return
|
||||
}
|
||||
if (type != "host_command") {
|
||||
if (type in VOICE_TYPES) {
|
||||
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
android:text="Status: —"
|
||||
android:paddingBottom="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnUpdate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="false"
|
||||
android:visibility="gone"
|
||||
android:text="Update installieren" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnScan"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Auto-Update: nur der Update-Ordner im Cache wird an den Installer freigegeben -->
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -143,6 +143,21 @@ for entry in "${ASSETS[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Auto-Update: Android-APK auf den RVS (eigener Ordner updates/agent/) ──
|
||||
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
|
||||
if [ -n "$RVS_UPDATE_HOST" ]; then
|
||||
echo -e "\n${GREEN}[+] Android-APK auf den RVS kopieren (Auto-Update)...${NC}"
|
||||
if ssh "$RVS_UPDATE_HOST" "mkdir -p ~/ARIA-AGENT/rvs/updates/agent && rm -f ~/ARIA-AGENT/rvs/updates/agent/*.apk" 2>/dev/null \
|
||||
&& scp "$APK" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} Agents bekommen v$VERSION beim naechsten Verbinden angeboten"
|
||||
else
|
||||
echo -e " ${YELLOW}Kopieren fehlgeschlagen (RVS_UPDATE_HOST=$RVS_UPDATE_HOST). Manuell:${NC}"
|
||||
echo -e " ${YELLOW}scp $APK $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "\n${YELLOW}Auto-Update uebersprungen (RVS_UPDATE_HOST nicht gesetzt)${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
||||
|
||||
Reference in New Issue
Block a user