feat(android-agent): Meilenstein 1 — verbinden + in Diagnostic sichtbar
Nativer Kotlin-Agent (host-agent/android/): Gradle-Projekt, AndroidManifest,
RVS-WebSocket-Client (OkHttp) mit host_hello/host_ping/host_command->host_result,
Foreground-Service (Weg A, Auto-Reconnect, BootReceiver), Connect-UI (QR-Scan via
ZXing ODER manuell: host/port/token/name/TLS/Steuerung-Schalter). QR-Format =
{host,port,token,tls} wie die ARIA-App -> derselbe QR nutzbar.
M1-Aktion: info (Modell/Android/Akku). screenshot/ui_* liefern 'kommt in M2/M3'.
Gate: CONTROL_ENABLED-Schalter in der App. Build: Docker (Android-SDK+Gradle) ->
dist/aria-android-agent.apk (Debug, auto-signiert). Kein Google.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Button>(R.id.btnScan).setOnClickListener {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED) launchScan()
|
||||
else camPerm.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
findViewById<Button>(R.id.btnConnect).setOnClickListener { saveAndConnect() }
|
||||
findViewById<Button>(R.id.btnStop).setOnClickListener {
|
||||
AgentService.stop(this)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
loadIntoUi(AgentConfig.load(this))
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
notifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val filter = IntentFilter(AgentService.ACTION_STATUS)
|
||||
ContextCompat.registerReceiver(this, statusReceiver, filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
try { unregisterReceiver(statusReceiver) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun launchScan() {
|
||||
val o = ScanOptions()
|
||||
.setBeepEnabled(false)
|
||||
.setOrientationLocked(false)
|
||||
.setPrompt("ARIA-Verbindungs-QR scannen")
|
||||
scan.launch(o)
|
||||
}
|
||||
|
||||
private fun applyQr(content: String) {
|
||||
try {
|
||||
val j = JSONObject(content)
|
||||
host.setText(j.optString("host"))
|
||||
port.setText((if (j.has("port")) j.optInt("port", 443) else 443).toString())
|
||||
token.setText(j.optString("token"))
|
||||
if (j.has("tls")) tls.isChecked = j.optBoolean("tls", true)
|
||||
toast("QR übernommen — jetzt 'Speichern & Verbinden'")
|
||||
} catch (_: Exception) {
|
||||
toast("Das ist kein ARIA-Verbindungs-QR")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadIntoUi(c: AgentConfig) {
|
||||
host.setText(c.host)
|
||||
port.setText(c.port.toString())
|
||||
token.setText(c.token)
|
||||
name.setText(c.name)
|
||||
tls.isChecked = c.tls
|
||||
control.isChecked = c.controlEnabled
|
||||
}
|
||||
|
||||
private fun saveAndConnect() {
|
||||
val c = AgentConfig(
|
||||
host = host.text.toString().trim(),
|
||||
port = port.text.toString().trim().toIntOrNull() ?: 443,
|
||||
tls = tls.isChecked,
|
||||
token = token.text.toString().trim(),
|
||||
name = name.text.toString().trim(),
|
||||
controlEnabled = control.isChecked,
|
||||
)
|
||||
if (!c.isValid()) {
|
||||
toast("Host und Token sind Pflicht")
|
||||
return
|
||||
}
|
||||
c.save(this)
|
||||
AgentService.start(this)
|
||||
toast("Agent gestartet")
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
private fun refreshStatus() {
|
||||
val dot = if (AgentService.connected) "🟢" else "🔴"
|
||||
statusView.text = "Status: ${AgentService.status} $dot"
|
||||
}
|
||||
|
||||
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* RVS-WebSocket-Client (Meilenstein 1). Spricht dasselbe Protokoll wie der
|
||||
* Desktop-Agent: meldet sich per host_hello, haelt sich per host_ping frisch,
|
||||
* beantwortet host_command -> host_result.
|
||||
*
|
||||
* M1-Aktionen: nur `info`. screenshot/ui_* folgen in M2/M3.
|
||||
*/
|
||||
class RvsClient(
|
||||
private val appCtx: Context,
|
||||
private val config: AgentConfig,
|
||||
private val onStatus: (connected: Boolean, msg: String) -> Unit,
|
||||
) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // Server-Push: nie lesen-timeouten
|
||||
.build()
|
||||
|
||||
private var ws: WebSocket? = null
|
||||
@Volatile private var running = false
|
||||
private var pingThread: Thread? = null
|
||||
|
||||
private val caps = listOf("info") // waechst in M2/M3
|
||||
|
||||
fun start() {
|
||||
running = true
|
||||
connect()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
pingThread?.interrupt()
|
||||
try { ws?.close(1000, "bye") } catch (_: Exception) {}
|
||||
ws = null
|
||||
}
|
||||
|
||||
private fun url(): String {
|
||||
val proto = if (config.tls) "wss" else "ws"
|
||||
return "$proto://${config.host}:${config.port}?token=${config.token}"
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (!running) return
|
||||
onStatus(false, "verbinde …")
|
||||
val req = Request.Builder().url(url()).build()
|
||||
ws = client.newWebSocket(req, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
onStatus(true, "verbunden")
|
||||
sendHello(webSocket)
|
||||
startPing(webSocket)
|
||||
}
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handle(webSocket, text)
|
||||
}
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
onStatus(false, "getrennt: ${t.message ?: "?"}")
|
||||
reconnectLater()
|
||||
}
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
onStatus(false, "geschlossen")
|
||||
reconnectLater()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun reconnectLater() {
|
||||
pingThread?.interrupt()
|
||||
if (!running) return
|
||||
Thread {
|
||||
try { Thread.sleep(3000) } catch (_: InterruptedException) { return@Thread }
|
||||
connect()
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun send(webSocket: WebSocket, type: String, payload: JSONObject) {
|
||||
val o = JSONObject()
|
||||
o.put("type", type)
|
||||
o.put("payload", payload)
|
||||
o.put("timestamp", System.currentTimeMillis())
|
||||
try { webSocket.send(o.toString()) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun sendHello(webSocket: WebSocket) {
|
||||
val p = JSONObject()
|
||||
p.put("hostId", config.hostId())
|
||||
p.put("name", config.displayName())
|
||||
p.put("os", "Android ${Build.VERSION.RELEASE} (${Build.MODEL})")
|
||||
p.put("caps", JSONArray(caps))
|
||||
p.put("control", config.controlEnabled)
|
||||
send(webSocket, "host_hello", p)
|
||||
}
|
||||
|
||||
private fun startPing(webSocket: WebSocket) {
|
||||
pingThread?.interrupt()
|
||||
pingThread = Thread {
|
||||
while (running && !Thread.currentThread().isInterrupted) {
|
||||
try { Thread.sleep(25000) } catch (_: InterruptedException) { break }
|
||||
val p = JSONObject().put("hostId", config.hostId())
|
||||
send(webSocket, "host_ping", p)
|
||||
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
||||
}
|
||||
}.also { it.start() }
|
||||
}
|
||||
|
||||
private fun handle(webSocket: WebSocket, text: String) {
|
||||
val msg = try { JSONObject(text) } catch (_: Exception) { return }
|
||||
if (msg.optString("type") != "host_command") return
|
||||
val payload = msg.optJSONObject("payload") ?: JSONObject()
|
||||
|
||||
val target = payload.optString("host").ifBlank { payload.optString("hostId") }
|
||||
if (target.isNotBlank()
|
||||
&& !target.equals(config.hostId(), true)
|
||||
&& !target.equals(config.displayName(), true)) return
|
||||
|
||||
val action = payload.optString("action")
|
||||
val result: JSONObject = when {
|
||||
!config.controlEnabled ->
|
||||
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
|
||||
action == "info" -> doInfo()
|
||||
action in listOf("screenshot", "ui_dump", "ui_tap", "ui_text",
|
||||
"ui_swipe", "ui_key", "app_launch", "app_list", "notify") ->
|
||||
err("Aktion '$action' kommt in Meilenstein 2/3 (noch nicht implementiert).")
|
||||
else -> err("Aktion '$action' unbekannt.")
|
||||
}
|
||||
result.put("requestId", payload.optString("requestId"))
|
||||
result.put("hostId", config.hostId())
|
||||
result.put("action", action)
|
||||
send(webSocket, "host_result", result)
|
||||
}
|
||||
|
||||
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
|
||||
|
||||
private fun doInfo(): JSONObject {
|
||||
val res = JSONObject()
|
||||
res.put("host", config.displayName())
|
||||
res.put("model", Build.MODEL)
|
||||
res.put("manufacturer", Build.MANUFACTURER)
|
||||
res.put("android", Build.VERSION.RELEASE)
|
||||
res.put("sdk", Build.VERSION.SDK_INT)
|
||||
try {
|
||||
val bm = appCtx.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||
res.put("battery_percent", bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
|
||||
} catch (_: Exception) {}
|
||||
return JSONObject().put("ok", true).put("result", res)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user