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