From 80d7f62eaa81742d251612865c78c2b24cfdcdd8 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 24 Sep 2026 21:14:15 +0200 Subject: [PATCH] =?UTF-8?q?fix(android-agent):=20Screenshot-Timeout=20?= =?UTF-8?q?=E2=80=94=20nie=20stumm=20scheitern=20+=20gekappte=20Aufnahme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeout hiess: kein host_result kam zurueck. Ursachen adressiert: 1) handle() umschliesst JEDE Aktion mit try/catch(Throwable) -> auch OOM/Exception liefert jetzt ein host_result mit Fehlertext statt Stille (kein Timeout mehr). 2) ScreenCapturer nimmt direkt in gekappter Aufloesung auf (max 1280 lange Seite, MediaProjection skaliert) statt Vollbild-Bitmap + Nachskalieren -> viel weniger Speicher/Zeit (kein 10-MB-ARGB-Bitmap -> kein OOM), Frame-Warten bis ~3s. lastError fliesst in die Screenshot-Fehlermeldung. Version 0.0.0.4/code 4. Co-Authored-By: Claude Opus 4.8 --- host-agent/android/app/build.gradle | 4 +- .../java/de/hackersoft/ariaagent/RvsClient.kt | 29 +++++---- .../de/hackersoft/ariaagent/ScreenCapturer.kt | 60 ++++++++++--------- 3 files changed, 53 insertions(+), 40 deletions(-) diff --git a/host-agent/android/app/build.gradle b/host-agent/android/app/build.gradle index e42cc36..97ec05c 100644 --- a/host-agent/android/app/build.gradle +++ b/host-agent/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId 'de.hackersoft.ariaagent' minSdk 26 targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34 - versionCode 3 - versionName '0.0.0.3' + versionCode 4 + versionName '0.0.0.4' } // Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt index b1cd70d..cec9ccb 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt @@ -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) diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ScreenCapturer.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ScreenCapturer.kt index 3cbaeee..b09f60c 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ScreenCapturer.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/ScreenCapturer.kt @@ -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, - ) - } }