Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27f4fbdd12 | ||
|
|
0acf96860e |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20509
|
versionCode 20600
|
||||||
versionName "0.2.5.9"
|
versionName "0.2.6.0"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.5.9",
|
"version": "0.2.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -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);
|
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
|
||||||
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
|
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
|
||||||
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
|
> (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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<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. -->
|
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
|
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
|
||||||
@@ -60,6 +62,17 @@
|
|||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
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
|
<service
|
||||||
android:name=".AgentService"
|
android:name=".AgentService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class AgentService : Service() {
|
|||||||
@Volatile var status: String = "gestoppt"
|
@Volatile var status: String = "gestoppt"
|
||||||
@Volatile var connected: Boolean = false
|
@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) {
|
fun start(ctx: Context) {
|
||||||
val i = Intent(ctx, AgentService::class.java)
|
val i = Intent(ctx, AgentService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
|
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() {
|
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 host: EditText
|
||||||
private lateinit var port: EditText
|
private lateinit var port: EditText
|
||||||
private lateinit var token: EditText
|
private lateinit var token: EditText
|
||||||
@@ -78,6 +83,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
tls = findViewById(R.id.tls)
|
tls = findViewById(R.id.tls)
|
||||||
control = findViewById(R.id.control)
|
control = findViewById(R.id.control)
|
||||||
statusView = findViewById(R.id.status)
|
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 {
|
findViewById<Button>(R.id.btnScan).setOnClickListener {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
@@ -209,13 +218,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun refreshStatus() {
|
private fun refreshStatus() {
|
||||||
val dot = if (AgentService.connected) "🟢" else "🔴"
|
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?) {
|
override fun onNewIntent(intent: Intent?) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
if (intent.action == ACTION_INSTALL_UPDATE) { AgentUpdater.install(this); return }
|
||||||
applyProvisioning(intent)
|
applyProvisioning(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class RvsClient(
|
|||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
onStatus(true, "verbunden")
|
onStatus(true, "verbunden")
|
||||||
sendHello(webSocket)
|
sendHello(webSocket)
|
||||||
|
sendUpdateCheck(webSocket)
|
||||||
startPing(webSocket)
|
startPing(webSocket)
|
||||||
}
|
}
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
@@ -141,9 +142,17 @@ class RvsClient(
|
|||||||
localIp()?.let { p.put("ip", it) }
|
localIp()?.let { p.put("ip", it) }
|
||||||
p.put("caps", JSONArray(caps))
|
p.put("caps", JSONArray(caps))
|
||||||
p.put("control", config.controlEnabled)
|
p.put("control", config.controlEnabled)
|
||||||
|
p.put("version", AgentUpdater.installedVersion(appCtx))
|
||||||
send(webSocket, "host_hello", p)
|
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). */
|
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
|
||||||
private fun localIp(): String? = try {
|
private fun localIp(): String? = try {
|
||||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||||
@@ -153,6 +162,8 @@ class RvsClient(
|
|||||||
?.hostAddress
|
?.hostAddress
|
||||||
} catch (_: Throwable) { null }
|
} catch (_: Throwable) { null }
|
||||||
|
|
||||||
|
private var pingCount = 0
|
||||||
|
|
||||||
private fun startPing(webSocket: WebSocket) {
|
private fun startPing(webSocket: WebSocket) {
|
||||||
pingThread?.interrupt()
|
pingThread?.interrupt()
|
||||||
pingThread = Thread {
|
pingThread = Thread {
|
||||||
@@ -161,6 +172,8 @@ class RvsClient(
|
|||||||
val p = JSONObject().put("hostId", config.hostId())
|
val p = JSONObject().put("hostId", config.hostId())
|
||||||
send(webSocket, "host_ping", p)
|
send(webSocket, "host_ping", p)
|
||||||
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
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() }
|
}.also { it.start() }
|
||||||
}
|
}
|
||||||
@@ -171,6 +184,11 @@ class RvsClient(
|
|||||||
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
|
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
|
||||||
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
|
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
|
||||||
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
|
// 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 != "host_command") {
|
||||||
if (type in VOICE_TYPES) {
|
if (type in VOICE_TYPES) {
|
||||||
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
|
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
android:text="Status: —"
|
android:text="Status: —"
|
||||||
android:paddingBottom="16dp" />
|
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
|
<Button
|
||||||
android:id="@+id/btnScan"
|
android:id="@+id/btnScan"
|
||||||
android:layout_width="match_parent"
|
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
|
fi
|
||||||
done
|
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 ""
|
||||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
||||||
|
|||||||
+67
-40
@@ -11,6 +11,9 @@ const path = require("path");
|
|||||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||||
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
||||||
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
|
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.
|
// HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
|
||||||
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
|
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
|
||||||
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
|
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
|
||||||
@@ -148,6 +151,8 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
|
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
|
||||||
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
|
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
|
||||||
"client_hello", "room_clients_query", "room_clients",
|
"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", "node_stats_stream_start", "node_stats_stream_stop",
|
||||||
"node_stats_history_request", "node_stats_history",
|
"node_stats_history_request", "node_stats_history",
|
||||||
"node_stats_reset", "node_stats_reset_done",
|
"node_stats_reset", "node_stats_reset_done",
|
||||||
@@ -403,48 +408,20 @@ function handleHttpRequest(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// App-Update als normaler HTTP-Download (statt eines riesigen WS-Blocks):
|
// Updates als normaler HTTP-Download (statt eines riesigen WS-Blocks):
|
||||||
// Content-Length fuer die Fortschrittsanzeige, Range zum Fortsetzen.
|
// Cockpit-App unter /update/latest.apk, Host-Agent (Android) unter
|
||||||
// Nur fuer Geraete mit gueltigem Raum-Token (Header X-RVS-Token).
|
// /update/agent/latest.apk. Nur mit gueltigem Raum-Token (X-RVS-Token).
|
||||||
if ((req.method === "GET" || req.method === "HEAD") && pathname === "/update/latest.apk") {
|
if ((req.method === "GET" || req.method === "HEAD")
|
||||||
|
&& (pathname === "/update/latest.apk" || pathname === "/update/agent/latest.apk")) {
|
||||||
const tok = String(req.headers["x-rvs-token"] || "");
|
const tok = String(req.headers["x-rvs-token"] || "");
|
||||||
if (!tok || !rooms.has(tok)) {
|
if (!tok || !rooms.has(tok)) {
|
||||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||||
res.end("Forbidden\n");
|
res.end("Forbidden\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const apk = getLatestAPK();
|
const isAgent = pathname.startsWith("/update/agent/");
|
||||||
if (!apk) {
|
serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(),
|
||||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
isAgent ? "aria-host-agent" : "ARIA");
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,6 +618,21 @@ function registerClient(ws, token) {
|
|||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Raum-Diagnose: direkt an den anfragenden Client antworten (nicht relay'en).
|
// Raum-Diagnose: direkt an den anfragenden Client antworten (nicht relay'en).
|
||||||
// Zeigt ALLE Raeume mit token8 (wie im Log), einem laengeren Fingerprint und
|
// Zeigt ALLE Raeume mit token8 (wie im Log), einem laengeren Fingerprint und
|
||||||
// der Token-Laenge — so werden Prefix-Kollisionen (2 Raeume, gleicher 8-Zeichen-
|
// der Token-Laenge — so werden Prefix-Kollisionen (2 Raeume, gleicher 8-Zeichen-
|
||||||
@@ -781,15 +773,50 @@ wss.on("close", () => {
|
|||||||
|
|
||||||
let latestVersion = null;
|
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 {
|
try {
|
||||||
if (!fs.existsSync(UPDATES_DIR)) return null;
|
if (!fs.existsSync(dir)) return null;
|
||||||
const files = fs.readdirSync(UPDATES_DIR)
|
const files = fs.readdirSync(dir)
|
||||||
.filter(f => f.endsWith(".apk"))
|
.filter(f => f.endsWith(".apk"))
|
||||||
.map(f => {
|
.map(f => {
|
||||||
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
||||||
const match = f.match(/(\d+(?:\.\d+){2,})/);
|
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)
|
.filter(f => f.version)
|
||||||
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
||||||
|
|||||||
Reference in New Issue
Block a user