Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b7a02b85 | ||
|
|
ae2024ef55 | ||
|
|
27f4fbdd12 | ||
|
|
0acf96860e |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20509
|
||||
versionName "0.2.5.9"
|
||||
versionCode 20601
|
||||
versionName "0.2.6.1"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.9",
|
||||
"version": "0.2.6.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -2539,8 +2539,7 @@ const SettingsScreen: React.FC = () => {
|
||||
style={[styles.connectButton, {marginTop: 12}]}
|
||||
onPress={() => {
|
||||
const updateService = require('../services/updater').default;
|
||||
updateService.checkForUpdate();
|
||||
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
|
||||
updateService.checkForUpdateManual();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
|
||||
|
||||
@@ -50,6 +50,9 @@ class UpdateService {
|
||||
private progressListeners: ProgressCallback[] = [];
|
||||
private progress: UpdateProgress = { active: false, version: '', fraction: -1, receivedMB: 0, totalMB: 0, status: '' };
|
||||
private jobId: number | null = null;
|
||||
/** Laeuft gerade eine Pruefung per Knopf? Dann geht die Antwort NUR dorthin
|
||||
* (sonst zeigt der ChatScreen-Listener denselben Dialog ein zweites Mal). */
|
||||
private manualWaiter: ((r: { type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string }) => void) | null = null;
|
||||
private cancelled = false;
|
||||
|
||||
constructor() {
|
||||
@@ -66,10 +69,19 @@ class UpdateService {
|
||||
downloadUrl: (msg.payload.downloadUrl as string) || '',
|
||||
size: (msg.payload.size as number) || 0,
|
||||
};
|
||||
if (this.manualWaiter) {
|
||||
const w = this.manualWaiter; this.manualWaiter = null;
|
||||
if (info.version && this.isNewer(info.version)) w({ type: 'available', info });
|
||||
else w({ type: 'none', latest: info.version });
|
||||
return;
|
||||
}
|
||||
if (info.version && this.isNewer(info.version)) {
|
||||
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
|
||||
this.listeners.forEach(cb => cb(info));
|
||||
}
|
||||
} else if ((msg.type as string) === 'update_none' && this.manualWaiter) {
|
||||
const w = this.manualWaiter; this.manualWaiter = null;
|
||||
w({ type: 'none', latest: String(msg.payload.latest || '') });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -150,6 +162,64 @@ class UpdateService {
|
||||
setTimeout(() => { this.checking = false; }, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefung per Knopf (Einstellungen → Ueber): sagt IMMER etwas —
|
||||
* neue Version (Update-Dialog), "keine neue Version" mit der Version auf dem
|
||||
* Server, oder warum der Update-Server nicht erreichbar ist (mit Details).
|
||||
*/
|
||||
async checkForUpdateManual(): Promise<void> {
|
||||
const cfg = rvs.getConfig();
|
||||
const where = cfg ? `${cfg.useTLS ? 'wss' : 'ws'}://${cfg.host}:${cfg.port}` : '(nicht eingerichtet)';
|
||||
if (!cfg) {
|
||||
Alert.alert('Update-Server nicht erreichbar', 'Es ist noch kein Server eingerichtet (Einstellungen → Verbindung).');
|
||||
return;
|
||||
}
|
||||
if (!rvs.isSocketOpen()) {
|
||||
const lastErr = [...rvs.getConnectionLog()].reverse().find(e => e.level === 'error');
|
||||
Alert.alert('Update-Server nicht erreichbar',
|
||||
`Keine Verbindung zu ${where}.\n\nZustand: ${rvs.getState()}`
|
||||
+ (lastErr ? `\nLetzter Fehler (${new Date(lastErr.timestamp).toLocaleTimeString()}):\n${lastErr.message}` : ''));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<{ type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string } | { type: 'timeout' }>(resolve => {
|
||||
const t = setTimeout(() => { this.manualWaiter = null; resolve({ type: 'timeout' }); }, 10_000);
|
||||
this.manualWaiter = (r) => { clearTimeout(t); resolve(r); };
|
||||
rvs.send('update_check' as any, { version: APP_VERSION });
|
||||
});
|
||||
|
||||
if (result.type === 'available') {
|
||||
this.promptUpdate(result.info);
|
||||
return;
|
||||
}
|
||||
if (result.type === 'timeout') {
|
||||
Alert.alert('Update-Server antwortet nicht',
|
||||
`Verbunden mit ${where}, aber nach 10 s keine Antwort auf die Update-Anfrage.\n\n`
|
||||
+ 'Moeglicherweise laeuft auf dem Server eine alte RVS-Version.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Keine neue Version — zusaetzlich pruefen, ob der Download-Weg funktioniert
|
||||
let dl = '';
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 10_000);
|
||||
const r = await fetch(`${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`,
|
||||
{ method: 'HEAD', headers: { 'X-RVS-Token': cfg.token }, signal: ctrl.signal });
|
||||
clearTimeout(t);
|
||||
dl = r.ok ? 'Download-Weg: erreichbar ✓'
|
||||
: r.status === 404 && result.latest ? 'Download-Weg: vom Server noch nicht unterstuetzt (alter RVS)'
|
||||
: r.status === 404 ? 'Download-Weg: erreichbar, aber auf dem Server liegt keine APK'
|
||||
: `Download-Weg: Fehler HTTP ${r.status}`;
|
||||
} catch (e: any) {
|
||||
dl = `Download-Weg: nicht erreichbar (${e?.name === 'AbortError' ? 'Zeitueberschreitung' : (e?.message || e)})`;
|
||||
}
|
||||
Alert.alert('Keine neue Version',
|
||||
`Installiert: ${APP_VERSION}\n`
|
||||
+ `Neueste auf dem Server: ${result.latest || '(keine APK hinterlegt)'}\n\n`
|
||||
+ `Update-Server: ${where} ✓\n${dl}`);
|
||||
}
|
||||
|
||||
/** Callback registrieren */
|
||||
onUpdateAvailable(callback: UpdateCallback): () => void {
|
||||
this.listeners.push(callback);
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+76
-41
@@ -11,6 +11,9 @@ const path = require("path");
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
||||
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
|
||||
// Host-Agent (Android) hat einen EIGENEN Ordner — sonst bekaeme die Cockpit-App
|
||||
// die Agent-APK angeboten (die Versions-Erkennung schaut nur auf den Dateinamen).
|
||||
const AGENT_UPDATES_DIR = path.join(UPDATES_DIR, "agent");
|
||||
// HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
|
||||
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
|
||||
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
|
||||
@@ -24,7 +27,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
|
||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||
"update_check", "update_available", "update_download", "update_data",
|
||||
"update_check", "update_available", "update_none", "update_download", "update_data",
|
||||
"agent_activity", "cancel_request", "interject",
|
||||
"audio_pcm",
|
||||
"file_from_aria",
|
||||
@@ -148,6 +151,8 @@ const ALLOWED_TYPES = new Set([
|
||||
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
|
||||
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
|
||||
"client_hello", "room_clients_query", "room_clients",
|
||||
// Host-Agent (Android): Update-Pruefung, beantwortet der RVS selbst
|
||||
"agent_update_check", "agent_update_available",
|
||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||
"node_stats_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
@@ -403,48 +408,20 @@ function handleHttpRequest(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
// App-Update als normaler HTTP-Download (statt eines riesigen WS-Blocks):
|
||||
// Content-Length fuer die Fortschrittsanzeige, Range zum Fortsetzen.
|
||||
// Nur fuer Geraete mit gueltigem Raum-Token (Header X-RVS-Token).
|
||||
if ((req.method === "GET" || req.method === "HEAD") && pathname === "/update/latest.apk") {
|
||||
// Updates als normaler HTTP-Download (statt eines riesigen WS-Blocks):
|
||||
// Cockpit-App unter /update/latest.apk, Host-Agent (Android) unter
|
||||
// /update/agent/latest.apk. Nur mit gueltigem Raum-Token (X-RVS-Token).
|
||||
if ((req.method === "GET" || req.method === "HEAD")
|
||||
&& (pathname === "/update/latest.apk" || pathname === "/update/agent/latest.apk")) {
|
||||
const tok = String(req.headers["x-rvs-token"] || "");
|
||||
if (!tok || !rooms.has(tok)) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||
res.end("Forbidden\n");
|
||||
return;
|
||||
}
|
||||
const apk = getLatestAPK();
|
||||
if (!apk) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Keine APK verfuegbar\n");
|
||||
return;
|
||||
}
|
||||
const size = fs.statSync(apk.path).size;
|
||||
const headers = {
|
||||
"Content-Type": "application/vnd.android.package-archive",
|
||||
"Content-Disposition": `attachment; filename="ARIA-v${apk.version}.apk"`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"X-APK-Version": apk.version,
|
||||
};
|
||||
let start = 0, end = size - 1, status = 200;
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ""));
|
||||
if (m && (m[1] || m[2])) {
|
||||
if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); }
|
||||
else { start = Math.max(0, size - parseInt(m[2], 10)); }
|
||||
if (start > end || start >= size) {
|
||||
res.writeHead(416, { "Content-Range": `bytes */${size}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
status = 206;
|
||||
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
|
||||
}
|
||||
headers["Content-Length"] = end - start + 1;
|
||||
res.writeHead(status, headers);
|
||||
if (req.method === "HEAD") { res.end(); return; }
|
||||
log(`APK-Download (HTTP) v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
|
||||
fs.createReadStream(apk.path, { start, end }).pipe(res);
|
||||
const isAgent = pathname.startsWith("/update/agent/");
|
||||
serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(),
|
||||
isAgent ? "aria-host-agent" : "ARIA");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -637,6 +614,29 @@ function registerClient(ws, token) {
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} else {
|
||||
// Auch "nichts Neues" beantworten — die App zeigt dann, dass der
|
||||
// Update-Server erreichbar ist und welche Version er hat
|
||||
ws.send(JSON.stringify({
|
||||
type: "update_none",
|
||||
payload: { latest: apkInfo ? apkInfo.version : "", current: clientVersion },
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Host-Agent (Android) fragt nach einem Update — direkt antworten
|
||||
if (msg.type === "agent_update_check") {
|
||||
const have = String(msg.payload?.version || "0.0.0.0");
|
||||
const apk = getLatestAPK(AGENT_UPDATES_DIR);
|
||||
if (apk && compareVersions(apk.version, have) > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "agent_update_available",
|
||||
payload: { version: apk.version, downloadUrl: "/update/agent/latest.apk",
|
||||
size: fs.statSync(apk.path).size },
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -781,15 +781,50 @@ wss.on("close", () => {
|
||||
|
||||
let latestVersion = null;
|
||||
|
||||
function getLatestAPK() {
|
||||
/** APK per HTTP ausliefern: Content-Length (Fortschritt), Range (Fortsetzen). */
|
||||
function serveApk(req, res, apk, label) {
|
||||
if (!apk) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Keine APK verfuegbar\n");
|
||||
return;
|
||||
}
|
||||
const size = fs.statSync(apk.path).size;
|
||||
const headers = {
|
||||
"Content-Type": "application/vnd.android.package-archive",
|
||||
"Content-Disposition": `attachment; filename="${label}-v${apk.version}.apk"`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"X-APK-Version": apk.version,
|
||||
};
|
||||
let start = 0, end = size - 1, status = 200;
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ""));
|
||||
if (m && (m[1] || m[2])) {
|
||||
if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); }
|
||||
else { start = Math.max(0, size - parseInt(m[2], 10)); }
|
||||
if (start > end || start >= size) {
|
||||
res.writeHead(416, { "Content-Range": `bytes */${size}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
status = 206;
|
||||
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
|
||||
}
|
||||
headers["Content-Length"] = end - start + 1;
|
||||
res.writeHead(status, headers);
|
||||
if (req.method === "HEAD") { res.end(); return; }
|
||||
log(`APK-Download (HTTP) ${label} v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
|
||||
fs.createReadStream(apk.path, { start, end }).pipe(res);
|
||||
}
|
||||
|
||||
function getLatestAPK(dir = UPDATES_DIR) {
|
||||
try {
|
||||
if (!fs.existsSync(UPDATES_DIR)) return null;
|
||||
const files = fs.readdirSync(UPDATES_DIR)
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith(".apk"))
|
||||
.map(f => {
|
||||
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
||||
const match = f.match(/(\d+(?:\.\d+){2,})/);
|
||||
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
|
||||
return { file: f, path: path.join(dir, f), version: match ? match[1] : null };
|
||||
})
|
||||
.filter(f => f.version)
|
||||
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
||||
|
||||
Reference in New Issue
Block a user