feat(android): Cockpit-App und Android-Agent per MDM ausrollen

Beide Apps lesen ihre Verbindungsdaten aus der App-Konfiguration des MDM
(Android Managed Configuration). Sind host und token gesetzt, verbindet sich
die App ohne QR-Code; die MDM-Werte haben Vorrang vor der Eingabe am Geraet.

- Cockpit (com.ariacockpit): host, token, port, tls; natives Modul
  MdmConfig, gelesen beim Start in rvs.loadConfig()
- Agent (de.hackersoft.ariaagent): zusaetzlich name, control, reply_mode;
  gelesen beim App-Start und nach dem Booten
- README: Abschnitt "Ausrollen per MDM" mit Screenshots unter docs/images/mdm/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 15:59:49 +01:00
co-authored by Claude Opus 5.5
parent 5cb2f71c41
commit 5912e47e32
15 changed files with 300 additions and 2 deletions
+60 -1
View File
@@ -97,7 +97,7 @@ ARIA hat zwei Rollen:
| Compute-Node(s) (STT/TTS/LLM) | 1..n GPU-Rechner | `cd xtts && cp .env.example .env && docker compose up -d` |
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
| Host-Agent(en) 💻 (optional) | Direkt auf einem Rechner | `cd host-agent && ./build.sh` → Binary + `.env` auf den Rechner, starten (Details unten & in [`host-agent/README.md`](host-agent/README.md)) |
| Android-Agent 📱📺 (optional) | Handy, Tablet, Fire TV | `cd host-agent/android && ./build.sh` → APK, oder per Satellit via ADB ausrollen (Details unten) |
| Android-Agent 📱📺 (optional) | Handy, Tablet, Fire TV | `cd host-agent/android && ./build.sh` → APK, oder per Satellit via ADB bzw. per MDM ausrollen (Details unten) |
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
> Compute-Nodes sind optional: ohne sie faellt STT auf lokales Whisper (CPU,
@@ -1267,6 +1267,65 @@ vorgeschlagen; nur wenn es keinen gibt, kommt der automatisch generierte.
---
## Ausrollen per MDM 📲 — Cockpit-App und Android-Agent
Beide Android-Apps lassen sich ueber ein MDM verteilen. Die Zugangsdaten gibt
das MDM als **App-Konfiguration** (Android „Managed Configuration") mit — am
Geraet muss niemand einen QR-Code scannen oder etwas eintippen.
Im MDM pro App den **Paketnamen** eintragen und darunter die Werte als
Schluessel / Typ / Wert:
![MDM: App-Konfiguration mit Paketname, Schluessel, Typ und Wert](docs/images/mdm/mdm-app-konfiguration.png)
Der Typ wird pro Zeile aus *Text*, *Zahl* oder *Ja/Nein* gewaehlt:
![MDM: Auswahl des Typs (Text, Zahl, Ja/Nein)](docs/images/mdm/mdm-typ-auswahl.png)
### Cockpit-App — Paketname `com.ariacockpit`
| Schluessel | Typ | Wert | Pflicht |
|------------|-----|------|---------|
| `host` | Text | RVS-Hostname, z.B. `aria-rvs.example.de` (= `RVS_HOST`) | ja |
| `token` | Text | Pairing-Token (= `RVS_TOKEN`, derselbe wie im QR-Code) | ja |
| `port` | Zahl | `443` (= `RVS_PORT`) | nein, Standard 443 |
| `tls` | Ja/Nein | Ja (= `RVS_TLS`) | nein, Standard Ja |
### Android-Agent — Paketname `de.hackersoft.ariaagent`
| Schluessel | Typ | Wert | Pflicht |
|------------|-----|------|---------|
| `host` | Text | RVS-Hostname (= `RVS_HOST`) | ja |
| `token` | Text | Pairing-Token (= `RVS_TOKEN`) | ja |
| `port` | Zahl | `443` | nein, Standard 443 |
| `tls` | Ja/Nein | Ja | nein, Standard Ja |
| `name` | Text | Geraetename, so erscheint er in der Diagnostic | nein, sonst der Modellname |
| `control` | Ja/Nein | Ja = ARIA darf das Geraet steuern (tippen, wischen, Apps starten) | nein, Standard Nein |
| `reply_mode` | Text | `overlay` (Einblendung) oder `speech` (Sprache) | nein, Standard `overlay` |
### Was dabei passiert
- **Nur mit `host` UND `token`** uebernimmt die App die Werte. Fehlt einer der
beiden, bleibt alles wie bisher (QR-Code bzw. Eingabe am Geraet).
- **Das MDM hat Vorrang.** Eine Aenderung im MDM (z.B. neuer Token) ueberschreibt
die Werte am Geraet. Beim Agenten bleiben nicht gesetzte Schluessel auf ihrem
bisherigen Wert.
- **Wann gelesen wird:** Cockpit beim App-Start, der Agent beim App-Start und
nach einem Neustart des Geraets. Eine Aenderung im MDM greift also erst danach.
- **Was das MDM nicht erledigen kann:** Beim Agenten muessen die
Bedienungshilfe (fuer `control`), die Einblendung ueber anderen Apps und die
Bildschirm-Aufnahme weiterhin einmal am Geraet erlaubt werden — Android laesst
diese Spezialberechtigungen nicht per App-Konfiguration zu. Cockpit fragt
Mikrofon, Benachrichtigungen usw. wie gewohnt beim ersten Start.
- Der **Token ist ein Zugangsschluessel**: Wer ihn kennt, landet im selben
RVS-Room. Im MDM nur fuer Admins sichtbar ablegen.
Welche Schluessel es gibt, steht in `res/xml/app_restrictions.xml` der
jeweiligen App (`android/android/app/...` bzw. `host-agent/android/app/...`);
ein MDM, das die Liste selbst ausliest, zeigt sie dort auch mit Beschreibung an.
---
## Docker Volumes
| Volume / Bind | Pfad im Container | Zweck |
@@ -38,6 +38,10 @@
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/AppTheme">
<!-- MDM-Ausrollen: welche Werte ein MDM mitgeben kann -->
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
<activity
android:name=".MainActivity"
android:label="@string/app_name"
@@ -27,6 +27,7 @@ class MainApplication : Application(), ReactApplication {
add(BackgroundAudioPackage())
add(ShareIntentPackage())
add(TlsTrustPackage())
add(MdmConfigPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,52 @@
package com.ariacockpit
import android.content.Context
import android.content.RestrictionsManager
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.uimanager.ViewManager
/**
* Verbindungsdaten aus dem MDM ("Managed Configuration" / App-Restrictions).
*
* Das MDM schreibt Schluessel/Werte fuer com.ariacockpit; welche es gibt,
* steht in res/xml/app_restrictions.xml. Die App liest sie beim Start —
* so muss beim Ausrollen niemand einen QR-Code scannen.
*
* Gibt nur die Schluessel zurueck, die das MDM wirklich gesetzt hat.
*/
class MdmConfigModule(ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {
override fun getName() = "MdmConfig"
@ReactMethod
fun getConfig(promise: Promise) {
try {
val rm = reactApplicationContext.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
val b = rm.applicationRestrictions
val out = Arguments.createMap()
for (key in b.keySet()) {
when (val v = b.get(key)) {
is String -> out.putString(key, v)
is Int -> out.putInt(key, v)
is Boolean -> out.putBoolean(key, v)
}
}
promise.resolve(out)
} catch (e: Exception) {
promise.resolve(Arguments.createMap())
}
}
}
class MdmConfigPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(MdmConfigModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Texte fuer die MDM-Werte (res/xml/app_restrictions.xml) -->
<resources>
<string name="mdm_host_title" translatable="false">RVS-Host</string>
<string name="mdm_host_description" translatable="false">Hostname des RVS, z.B. aria-rvs.example.de</string>
<string name="mdm_port_title" translatable="false">RVS-Port</string>
<string name="mdm_tls_title" translatable="false">TLS verwenden</string>
<string name="mdm_token_title" translatable="false">Token</string>
<string name="mdm_token_description" translatable="false">Derselbe Token wie im QR-Code</string>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Werte, die ein MDM beim Ausrollen mitgeben kann (Managed Configuration).
Sind host UND token gesetzt, verbindet sich die App damit — ohne QR-Code. -->
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
<restriction
android:key="host"
android:title="@string/mdm_host_title"
android:description="@string/mdm_host_description"
android:restrictionType="string" />
<restriction
android:key="port"
android:title="@string/mdm_port_title"
android:defaultValue="443"
android:restrictionType="integer" />
<restriction
android:key="tls"
android:title="@string/mdm_tls_title"
android:defaultValue="true"
android:restrictionType="bool" />
<restriction
android:key="token"
android:title="@string/mdm_token_title"
android:description="@string/mdm_token_description"
android:restrictionType="string" />
</restrictions>
+33
View File
@@ -491,7 +491,40 @@ class RVSConnection {
}
}
/**
* Verbindungsdaten aus dem MDM (Managed Configuration), falls beim Ausrollen
* gesetzt. Nur gueltig mit host UND token. Schluessel: res/xml/app_restrictions.xml
*/
async loadMdmConfig(): Promise<ConnectionConfig | null> {
try {
const Mdm: any = NativeModules.MdmConfig;
if (Platform.OS !== 'android' || !Mdm?.getConfig) return null;
const m = await Mdm.getConfig();
const host = String(m?.host ?? '').trim();
const token = String(m?.token ?? '').trim();
if (!host || !token) return null;
const port = Number(m.port);
return {
host,
token,
port: Number.isFinite(port) && port > 0 ? port : 443,
useTLS: m.tls !== false,
};
} catch {
return null;
}
}
async loadConfig(): Promise<ConnectionConfig | null> {
// Vom MDM gesetzte Werte haben Vorrang: so kommen geaenderte Zugangsdaten
// beim naechsten App-Start an, ohne dass jemand am Geraet etwas tun muss.
const mdm = await this.loadMdmConfig();
if (mdm) {
console.log('[RVS] Konfiguration aus dem MDM');
this.config = mdm;
await this.saveConfig(mdm);
return mdm;
}
try {
const data = await AsyncStorage.getItem(RVSConnection.STORAGE_KEY);
if (data) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -37,6 +37,11 @@
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.DayNight">
<!-- MDM-Ausrollen: welche Werte ein MDM mitgeben kann -->
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -8,6 +8,7 @@ import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
MdmConfig.apply(ctx) // vom MDM geaenderte Werte uebernehmen
if (AgentConfig.load(ctx).isValid()) {
AgentService.start(ctx)
}
@@ -134,7 +134,15 @@ class MainActivity : AppCompatActivity() {
// Fern-Einrichtung (z.B. Fire TV, wo QR-Scan und Tippen wegfallen): der
// Satellit startet die App per ADB mit den Zugangsdaten als Extras.
// Hat das geklappt, ist die UI schon befuellt — sonst normal laden.
if (!applyProvisioning(intent)) loadIntoUi(AgentConfig.load(this))
if (!applyProvisioning(intent)) {
// Per MDM ausgerollt? Dann Werte uebernehmen und gleich verbinden.
val mdm = MdmConfig.apply(this)
if (mdm != null) {
AgentService.start(this)
toast("Vom MDM eingerichtet: ${mdm.host} — Agent gestartet")
}
loadIntoUi(mdm ?: AgentConfig.load(this))
}
// Fire TV: kein Touchscreen — der QR-Scan ist dort sinnlos (keine Kamera),
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
@@ -0,0 +1,44 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.content.RestrictionsManager
/**
* Einrichtung per MDM ("Managed Configuration" / App-Restrictions).
*
* Das MDM schreibt Schluessel/Werte fuer de.hackersoft.ariaagent; welche es
* gibt, steht in res/xml/app_restrictions.xml. Sind host UND token gesetzt,
* uebernimmt der Agent sie — vom MDM gesetzte Werte haben Vorrang vor der
* Eingabe am Geraet, nicht gesetzte Schluessel lassen den alten Wert stehen.
*
* Gelesen wird beim App-Start und nach dem Booten.
*/
object MdmConfig {
/** Gibt die neue Konfiguration zurueck, wenn das MDM etwas geaendert hat, sonst null. */
fun apply(ctx: Context): AgentConfig? {
val b = try {
(ctx.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager)
.applicationRestrictions
} catch (_: Exception) { return null }
val host = b.getString("host")?.trim().orEmpty()
val token = b.getString("token")?.trim().orEmpty()
if (host.isEmpty() || token.isEmpty()) return null
val old = AgentConfig.load(ctx)
val cfg = old.copy(
host = host,
token = token,
port = if (b.containsKey("port")) b.getInt("port").takeIf { it > 0 } ?: 443 else old.port,
tls = if (b.containsKey("tls")) b.getBoolean("tls") else old.tls,
name = b.getString("name")?.trim().takeUnless { it.isNullOrEmpty() } ?: old.name,
controlEnabled = if (b.containsKey("control")) b.getBoolean("control") else old.controlEnabled,
replyMode = b.getString("reply_mode")?.trim()
?.takeIf { it == "overlay" || it == "speech" } ?: old.replyMode,
)
if (cfg == old) return null
cfg.save(ctx)
return cfg
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Texte fuer die MDM-Werte (res/xml/app_restrictions.xml) -->
<resources>
<string name="mdm_host_title" translatable="false">RVS-Host</string>
<string name="mdm_host_description" translatable="false">Hostname des RVS, z.B. aria-rvs.example.de</string>
<string name="mdm_port_title" translatable="false">RVS-Port</string>
<string name="mdm_tls_title" translatable="false">TLS verwenden</string>
<string name="mdm_token_title" translatable="false">Token</string>
<string name="mdm_token_description" translatable="false">Derselbe Token wie im QR-Code</string>
<string name="mdm_name_title" translatable="false">Geraetename</string>
<string name="mdm_name_description" translatable="false">So erscheint das Geraet in der Diagnostic (leer = Modellname)</string>
<string name="mdm_control_title" translatable="false">Steuern erlauben</string>
<string name="mdm_reply_mode_title" translatable="false">Antwort als</string>
<string name="mdm_reply_mode_description" translatable="false">overlay (Einblendung) oder speech (Sprache)</string>
</resources>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Werte, die ein MDM beim Ausrollen mitgeben kann (Managed Configuration).
Sind host UND token gesetzt, verbindet sich der Agent damit — ohne QR-Code. -->
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
<restriction
android:key="host"
android:title="@string/mdm_host_title"
android:description="@string/mdm_host_description"
android:restrictionType="string" />
<restriction
android:key="port"
android:title="@string/mdm_port_title"
android:defaultValue="443"
android:restrictionType="integer" />
<restriction
android:key="tls"
android:title="@string/mdm_tls_title"
android:defaultValue="true"
android:restrictionType="bool" />
<restriction
android:key="token"
android:title="@string/mdm_token_title"
android:description="@string/mdm_token_description"
android:restrictionType="string" />
<restriction
android:key="name"
android:title="@string/mdm_name_title"
android:description="@string/mdm_name_description"
android:restrictionType="string" />
<restriction
android:key="control"
android:title="@string/mdm_control_title"
android:defaultValue="false"
android:restrictionType="bool" />
<restriction
android:key="reply_mode"
android:title="@string/mdm_reply_mode_title"
android:description="@string/mdm_reply_mode_description"
android:defaultValue="overlay"
android:restrictionType="string" />
</restrictions>