Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d7f62eaa | ||
|
|
5f5234ee62 | ||
|
|
28e58089aa |
@@ -6,3 +6,6 @@ local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
captures/
|
||||
|
||||
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
|
||||
aria-agent.keystore
|
||||
|
||||
@@ -11,13 +11,33 @@ android {
|
||||
applicationId 'de.hackersoft.ariaagent'
|
||||
minSdk 26
|
||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||
versionCode 2
|
||||
versionName '0.0.0.2'
|
||||
versionCode 4
|
||||
versionName '0.0.0.4'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
// Android neue APKs als Update derselben App akzeptiert (sonst "Konflikt mit
|
||||
// bestehendem Paket"). Die Keystore-Datei liegt NUR lokal (gitignored, nicht
|
||||
// im oeffentlichen Repo) — UNBEDINGT sichern, sonst brechen kuenftige Updates.
|
||||
def ariaKeystore = rootProject.file('aria-agent.keystore')
|
||||
signingConfigs {
|
||||
aria {
|
||||
if (ariaKeystore.exists()) {
|
||||
storeFile ariaKeystore
|
||||
storePassword 'ariaagent'
|
||||
keyAlias 'aria'
|
||||
keyPassword 'ariaagent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
||||
@@ -126,16 +126,23 @@ class RvsClient(
|
||||
&& !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 == "screenshot" -> doScreenshot()
|
||||
action == "ui_dump" -> doUiDump()
|
||||
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
|
||||
"app_launch", "app_list", "notify") ->
|
||||
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
|
||||
else -> err("Aktion '$action' unbekannt.")
|
||||
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
|
||||
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
|
||||
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
|
||||
val result: JSONObject = try {
|
||||
when {
|
||||
!config.controlEnabled ->
|
||||
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
|
||||
action == "info" -> doInfo()
|
||||
action == "screenshot" -> doScreenshot()
|
||||
action == "ui_dump" -> doUiDump()
|
||||
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
|
||||
"app_launch", "app_list", "notify") ->
|
||||
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
|
||||
else -> err("Aktion '$action' unbekannt.")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
result.put("requestId", payload.optString("requestId"))
|
||||
result.put("hostId", config.hostId())
|
||||
@@ -153,7 +160,7 @@ class RvsClient(
|
||||
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
|
||||
}
|
||||
val png = ScreenCapturer.capture()
|
||||
?: return err("Screenshot fehlgeschlagen (kein Frame). Ist der Bildschirm an?")
|
||||
?: return err("Screenshot fehlgeschlagen: ${ScreenCapturer.lastError ?: "unbekannt"}")
|
||||
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
|
||||
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
|
||||
return JSONObject().put("ok", true).put("result", res)
|
||||
|
||||
@@ -23,10 +23,16 @@ import java.io.ByteArrayOutputStream
|
||||
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
|
||||
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
|
||||
*
|
||||
* Aufgenommen wird direkt in GEKAPPTER Aufloesung (max. 1280 lange Seite): die
|
||||
* MediaProjection skaliert den Bildschirminhalt auf die VirtualDisplay-Groesse.
|
||||
* Das haelt Speicher/Zeit klein (kein 10-MB-Vollbild-Bitmap -> kein OOM/Timeout).
|
||||
*
|
||||
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
|
||||
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
|
||||
*/
|
||||
object ScreenCapturer {
|
||||
private const val MAX_SIDE = 1280
|
||||
|
||||
private var projection: MediaProjection? = null
|
||||
private var reader: ImageReader? = null
|
||||
private var vdisplay: VirtualDisplay? = null
|
||||
@@ -56,9 +62,12 @@ object ScreenCapturer {
|
||||
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealMetrics(metrics)
|
||||
w = metrics.widthPixels
|
||||
h = metrics.heightPixels
|
||||
dpi = metrics.densityDpi
|
||||
// Direkt gekappt aufnehmen (lange Seite <= MAX_SIDE), Seitenverhaeltnis wahren.
|
||||
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
|
||||
val scale = if (longSide > MAX_SIDE) MAX_SIDE.toFloat() / longSide else 1f
|
||||
w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
|
||||
h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
|
||||
|
||||
handlerThread = HandlerThread("aria-capture").also { it.start() }
|
||||
handler = Handler(handlerThread!!.looper)
|
||||
@@ -94,50 +103,47 @@ object ScreenCapturer {
|
||||
handler = null
|
||||
}
|
||||
|
||||
/** PNG-Bytes des aktuellen Bildschirms, oder null. Wartet kurz auf einen Frame. */
|
||||
/** PNG-Bytes des aktuellen Bildschirms, oder null (Grund in lastError). */
|
||||
fun capture(): ByteArray? {
|
||||
val r = reader ?: return null
|
||||
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
|
||||
var image: Image? = null
|
||||
var tries = 0
|
||||
while (tries < 20) {
|
||||
// Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
|
||||
while (tries < 37) {
|
||||
image = r.acquireLatestImage()
|
||||
if (image != null) break
|
||||
try { Thread.sleep(80) } catch (_: InterruptedException) {}
|
||||
tries++
|
||||
}
|
||||
if (image == null) return null
|
||||
if (image == null) {
|
||||
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val pixelStride = plane.pixelStride
|
||||
val rowStride = plane.rowStride
|
||||
val rowPadding = rowStride - pixelStride * w
|
||||
val padded = Bitmap.createBitmap(
|
||||
w + (if (pixelStride > 0) rowPadding / pixelStride else 0),
|
||||
h, Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
padded.copyPixelsFromBuffer(buffer)
|
||||
val cropped = if (rowPadding == 0) padded else Bitmap.createBitmap(padded, 0, 0, w, h)
|
||||
val scaled = downscale(cropped, 1280)
|
||||
val bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
|
||||
val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
|
||||
bmp.copyPixelsFromBuffer(buffer)
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
if (scaled !== cropped) scaled.recycle()
|
||||
if (cropped !== padded) cropped.recycle()
|
||||
padded.recycle()
|
||||
if (rowPadding == 0) {
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
} else {
|
||||
val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
|
||||
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
cropped.recycle()
|
||||
}
|
||||
bmp.recycle()
|
||||
lastError = null
|
||||
out.toByteArray()
|
||||
} catch (_: Exception) {
|
||||
} catch (t: Throwable) {
|
||||
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
|
||||
null
|
||||
} finally {
|
||||
try { image?.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downscale(src: Bitmap, maxSide: Int): Bitmap {
|
||||
val longSide = maxOf(src.width, src.height)
|
||||
if (longSide <= maxSide) return src
|
||||
val scale = maxSide.toFloat() / longSide
|
||||
return Bitmap.createScaledBitmap(
|
||||
src, (src.width * scale).toInt(), (src.height * scale).toInt(), true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
|
||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||
|
||||
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||
AGENT_VERSION = "0.0.0.2"
|
||||
AGENT_VERSION = "0.0.0.3"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
||||
|
||||
Reference in New Issue
Block a user