diff --git a/host-agent/android/.gitignore b/host-agent/android/.gitignore
new file mode 100644
index 0000000..37f7437
--- /dev/null
+++ b/host-agent/android/.gitignore
@@ -0,0 +1,8 @@
+build/
+.gradle/
+dist/
+*.apk
+local.properties
+.idea/
+*.iml
+captures/
diff --git a/host-agent/android/Dockerfile.build b/host-agent/android/Dockerfile.build
new file mode 100644
index 0000000..fddf045
--- /dev/null
+++ b/host-agent/android/Dockerfile.build
@@ -0,0 +1,32 @@
+# Baut die Android-Agent-APK (Debug, auto-signiert -> direkt installierbar).
+# APK-Builds gehen nur unter Linux — deshalb im Container.
+FROM eclipse-temurin:17-jdk-jammy
+
+ARG GRADLE_VERSION=8.5
+ARG CMDLINE_TOOLS=11076708
+ENV ANDROID_SDK_ROOT=/opt/android-sdk
+ENV ANDROID_HOME=/opt/android-sdk
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ unzip wget ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+# Gradle
+RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/g.zip \
+ && unzip -q /tmp/g.zip -d /opt && rm /tmp/g.zip
+
+# Android cmdline-tools + SDK
+RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
+ && wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/c.zip \
+ && unzip -q /tmp/c.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/c.zip \
+ && mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest
+
+ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
+
+RUN yes | sdkmanager --licenses >/dev/null 2>&1 || true
+RUN sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null 2>&1
+
+WORKDIR /project
+COPY . /project
+
+CMD ["bash","-lc","gradle --no-daemon assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk /out/aria-android-agent.apk && echo 'OK -> /out/aria-android-agent.apk'"]
diff --git a/host-agent/android/app/build.gradle b/host-agent/android/app/build.gradle
new file mode 100644
index 0000000..12bfc70
--- /dev/null
+++ b/host-agent/android/app/build.gradle
@@ -0,0 +1,37 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+}
+
+android {
+ namespace 'de.hackersoft.ariaagent'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId 'de.hackersoft.ariaagent'
+ minSdk 26
+ targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
+ versionCode 1
+ versionName '0.1.0'
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+}
+
+dependencies {
+ implementation 'androidx.core:core-ktx:1.12.0'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0' // RVS-WebSocket
+ implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // QR-Scan (FOSS, kein Google-Dienst)
+}
diff --git a/host-agent/android/app/src/main/AndroidManifest.xml b/host-agent/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c165528
--- /dev/null
+++ b/host-agent/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentConfig.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentConfig.kt
new file mode 100644
index 0000000..5bf687a
--- /dev/null
+++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentConfig.kt
@@ -0,0 +1,53 @@
+package de.hackersoft.ariaagent
+
+import android.content.Context
+import android.os.Build
+
+/** Verbindungs-/Agent-Einstellungen (in SharedPreferences persistiert). */
+data class AgentConfig(
+ var host: String = "",
+ var port: Int = 443,
+ var tls: Boolean = true,
+ var token: String = "",
+ var name: String = "",
+ var controlEnabled: Boolean = false,
+) {
+ companion object {
+ private const val PREFS = "aria_agent"
+
+ fun load(ctx: Context): AgentConfig {
+ val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ return AgentConfig(
+ host = p.getString("host", "") ?: "",
+ port = p.getInt("port", 443),
+ tls = p.getBoolean("tls", true),
+ token = p.getString("token", "") ?: "",
+ name = p.getString("name", "") ?: "",
+ controlEnabled = p.getBoolean("control", false),
+ )
+ }
+ }
+
+ fun save(ctx: Context) {
+ ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
+ putString("host", host)
+ putInt("port", port)
+ putBoolean("tls", tls)
+ putString("token", token)
+ putString("name", name)
+ putBoolean("control", controlEnabled)
+ apply()
+ }
+ }
+
+ fun isValid(): Boolean = host.isNotBlank() && token.isNotBlank()
+
+ fun displayName(): String = if (name.isNotBlank()) name else Build.MODEL
+
+ /** Stabile, technische Host-ID (a-z0-9_-), wie beim Desktop-Agent. */
+ fun hostId(): String {
+ val base = displayName().lowercase()
+ .replace(Regex("[^a-z0-9_-]+"), "-").trim('-')
+ return base.ifBlank { "android" }
+ }
+}
diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentService.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentService.kt
new file mode 100644
index 0000000..4908726
--- /dev/null
+++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/AgentService.kt
@@ -0,0 +1,100 @@
+package de.hackersoft.ariaagent
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+
+/**
+ * Foreground-Service (Weg A): haelt die RVS-Verbindung dauerhaft, damit ARIA
+ * den Agenten jederzeit erreicht. Persistente Notification + Auto-Reconnect.
+ */
+class AgentService : Service() {
+
+ private var rvs: RvsClient? = null
+
+ companion object {
+ private const val CH = "aria_agent"
+ private const val NOTIF_ID = 1
+ const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS"
+
+ @Volatile var status: String = "gestoppt"
+ @Volatile var connected: Boolean = false
+
+ fun start(ctx: Context) {
+ val i = Intent(ctx, AgentService::class.java)
+ if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
+ }
+
+ fun stop(ctx: Context) {
+ ctx.stopService(Intent(ctx, AgentService::class.java))
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ createChannel()
+ startForeground(NOTIF_ID, buildNotification("startet …"))
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val cfg = AgentConfig.load(this)
+ if (!cfg.isValid()) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ rvs?.stop()
+ rvs = RvsClient(applicationContext, cfg) { conn, msg ->
+ connected = conn
+ status = msg
+ updateNotification(msg)
+ sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
+ }
+ rvs?.start()
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ rvs?.stop()
+ connected = false
+ status = "gestoppt"
+ sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT >= 26) {
+ val ch = NotificationChannel(CH, "ARIA Agent", NotificationManager.IMPORTANCE_LOW)
+ ch.setShowBadge(false)
+ (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
+ .createNotificationChannel(ch)
+ }
+ }
+
+ private fun buildNotification(text: String): Notification {
+ val pi = PendingIntent.getActivity(
+ this, 0, Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ return NotificationCompat.Builder(this, CH)
+ .setContentTitle("ARIA Host-Agent")
+ .setContentText(text)
+ .setSmallIcon(R.drawable.ic_launcher)
+ .setOngoing(true)
+ .setContentIntent(pi)
+ .build()
+ }
+
+ private fun updateNotification(text: String) {
+ (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
+ .notify(NOTIF_ID, buildNotification(text))
+ }
+}
diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/BootReceiver.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/BootReceiver.kt
new file mode 100644
index 0000000..504c439
--- /dev/null
+++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/BootReceiver.kt
@@ -0,0 +1,16 @@
+package de.hackersoft.ariaagent
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+/** Startet den Agent-Service nach dem Booten wieder (wenn konfiguriert). */
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
+ if (AgentConfig.load(ctx).isValid()) {
+ AgentService.start(ctx)
+ }
+ }
+ }
+}
diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt
new file mode 100644
index 0000000..dbd00c2
--- /dev/null
+++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/MainActivity.kt
@@ -0,0 +1,145 @@
+package de.hackersoft.ariaagent
+
+import android.Manifest
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.widget.Button
+import android.widget.EditText
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.SwitchCompat
+import androidx.core.content.ContextCompat
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+import org.json.JSONObject
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var host: EditText
+ private lateinit var port: EditText
+ private lateinit var token: EditText
+ private lateinit var name: EditText
+ private lateinit var tls: SwitchCompat
+ private lateinit var control: SwitchCompat
+ private lateinit var statusView: TextView
+
+ private val scan = registerForActivityResult(ScanContract()) { res ->
+ res.contents?.let { applyQr(it) }
+ }
+ private val camPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) launchScan() else toast("Kamera-Berechtigung nötig für QR-Scan")
+ }
+ private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
+
+ private val statusReceiver = object : BroadcastReceiver() {
+ override fun onReceive(c: Context?, i: Intent?) = refreshStatus()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ host = findViewById(R.id.host)
+ port = findViewById(R.id.port)
+ token = findViewById(R.id.token)
+ name = findViewById(R.id.name)
+ tls = findViewById(R.id.tls)
+ control = findViewById(R.id.control)
+ statusView = findViewById(R.id.status)
+
+ findViewById