feat(android-agent): Fire TV / Android TV Support (eine APK fuer Handy + Stick)

Kein zweiter Agent: dieselbe APK laeuft auf Fire TV, Fähigkeiten capability-gated
(wie beim Desktop-Agent).

- Manifest: touchscreen/faketouch/leanback als required=false (sonst gilt die App
  als Touch-Pflicht bzw. laeuft nicht mehr aufs Handy), LEANBACK_LAUNCHER (ohne
  das fehlt die App im Fire-TV-Menue), TV-Banner (Launcher zeigt Banner statt Icon).
- RvsClient: isTv-Erkennung (leanback/television/UiModeManager) -> caps bekommen
  'tv', os meldet 'Fire TV / Android TV'.
- AriaAccessibilityService.globalKey: D-Pad (up/down/left/right/ok) via Fokus-
  Navigation, Medientasten via dispatchMediaKeyEvent, Lautstaerke/Mute via
  AudioManager — deutsche Aliase (hoch/runter/lauter/stumm).
- MainActivity/Layout: auf TV QR-Scan ausgeblendet (keine Kamera), erster Fokus
  gesetzt, Buttons fokussierbar (Fernbedienung).
Version 0.0.0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 18:26:09 +02:00
co-authored by Claude Opus 4.8
parent 176997a800
commit 39f908dd4a
7 changed files with 180 additions and 10 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 7
versionName '0.0.0.7'
versionCode 8
versionName '0.0.0.8'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
@@ -12,10 +12,17 @@
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Fire TV / Android TV: dieselbe APK laeuft auf Stick UND Handy.
touchscreen/faketouch NICHT erforderlich (Fire TV hat keinen), und
leanback ebenfalls optional (sonst laeuft sie nicht mehr aufs Handy). -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.faketouch" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:banner="@drawable/banner_tv"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
@@ -28,6 +35,9 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- Ohne LEANBACK_LAUNCHER taucht die App im Fire-TV-Menue NICHT
auf (sie waere installiert, aber nicht startbar). -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
@@ -5,6 +5,7 @@ import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import org.json.JSONArray
@@ -148,19 +149,76 @@ class AriaAccessibilityService : AccessibilityService() {
return if (node.isEditable && r.contains(x, y)) node else null
}
/** Globale Taste: back/home/recents/notifications. */
/** Globale Taste: back/home/recents/notifications — plus D-Pad (Fire TV). */
fun globalKey(name: String): JSONObject {
val action = when (name.trim().lowercase()) {
val key = name.trim().lowercase()
// D-Pad/Medien: auf dem Fernseher wird damit navigiert (kein Touch).
DPAD_KEYS[key]?.let { return dpad(it, key) }
val action = when (key) {
"back", "zurueck", "zurück" -> GLOBAL_ACTION_BACK
"home", "start", "startseite" -> GLOBAL_ACTION_HOME
"recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS
"notifications", "benachrichtigungen" -> GLOBAL_ACTION_NOTIFICATIONS
else -> return errMsg("Taste '$name' unbekannt (back/home/recents/notifications)")
else -> return errMsg("Taste '$name' unbekannt. Verfuegbar: back, home, " +
"recents, notifications, up, down, left, right, ok/center, " +
"play_pause, next, prev, volume_up, volume_down, mute")
}
return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt")
else errMsg("Taste '$name' fehlgeschlagen")
}
/** D-Pad/Medien-Taste — via Fokus-Navigation bzw. Medien-Session. */
private fun dpad(keyCode: Int, label: String): JSONObject {
// Lautstaerke/Stumm gehen ueber den AudioManager (zuverlaessiger als KeyEvent).
when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP,
android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
android.view.KeyEvent.KEYCODE_VOLUME_MUTE -> {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP -> android.media.AudioManager.ADJUST_RAISE
android.view.KeyEvent.KEYCODE_VOLUME_DOWN -> android.media.AudioManager.ADJUST_LOWER
else -> android.media.AudioManager.ADJUST_TOGGLE_MUTE
}
am.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, dir,
android.media.AudioManager.FLAG_SHOW_UI)
return okMsg("Taste '$label' ausgefuehrt")
}
}
// Medientasten an die aktive Wiedergabe-Session.
if (keyCode in MEDIA_KEYS) {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val now = android.os.SystemClock.uptimeMillis()
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_DOWN, keyCode, 0))
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_UP, keyCode, 0))
return okMsg("Taste '$label' ausgefuehrt")
}
// Richtungen/OK: ueber die Bedienungshilfe-Fokus-Navigation.
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER) {
val target = focus ?: return errMsg("nichts fokussiert — vorher navigieren")
return if (target.performAction(AccessibilityNodeInfo.ACTION_CLICK))
okMsg("OK/Auswahl ausgefuehrt") else errMsg("Auswahl fehlgeschlagen")
}
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT
else -> View.FOCUS_RIGHT
}
val from = focus ?: root
val next = from.focusSearch(dir)
?: return errMsg("kein Element in Richtung '$label' gefunden")
return if (next.performAction(AccessibilityNodeInfo.ACTION_FOCUS))
okMsg("Taste '$label' ausgefuehrt") else errMsg("Taste '$label' fehlgeschlagen")
}
private fun okMsg(m: String): JSONObject =
JSONObject().put("ok", true).put("result", JSONObject().put("message", m))
@@ -172,5 +230,46 @@ class AriaAccessibilityService : AccessibilityService() {
var instance: AriaAccessibilityService? = null
val available: Boolean get() = instance != null
/** Fernseher-Navigation (Fire TV): D-Pad, Medien- und Lautstaerke-Tasten. */
private val DPAD_KEYS: Map<String, Int> = mapOf(
"up" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"hoch" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"down" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"runter" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"left" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"links" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"right" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"rechts" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"ok" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"center" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"select" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"enter" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"play_pause" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
"play" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
"pause" to android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
"stop" to android.view.KeyEvent.KEYCODE_MEDIA_STOP,
"next" to android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
"prev" to android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
"forward" to android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
"rewind" to android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
"volume_up" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"lauter" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"volume_down" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"leiser" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"mute" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
"stumm" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
)
private val MEDIA_KEYS: Set<Int> = setOf(
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_STOP,
android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
)
}
}
@@ -101,6 +101,13 @@ class MainActivity : AppCompatActivity() {
loadIntoUi(AgentConfig.load(this))
// Fire TV: kein Touchscreen — der QR-Scan ist dort sinnlos (keine Kamera),
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
if (isTvDevice()) {
findViewById<Button>(R.id.btnScan).visibility = android.view.View.GONE
host.requestFocus()
}
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
@@ -175,5 +182,11 @@ class MainActivity : AppCompatActivity() {
statusView.text = "Status: ${AgentService.status} $dot"
}
/** Fire TV / Android TV (kein Touchscreen, Bedienung per Fernbedienung). */
private fun isTvDevice(): Boolean = try {
packageManager.hasSystemFeature("android.software.leanback") ||
packageManager.hasSystemFeature("android.hardware.type.television")
} catch (_: Exception) { false }
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
}
@@ -33,10 +33,23 @@ class RvsClient(
@Volatile private var running = false
private var pingThread: Thread? = null
private val caps = listOf(
"info", "screenshot", "ui_dump",
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
)
/** Fire TV / Android TV? Dann gibt es keinen Touchscreen, dafuer D-Pad. */
private val isTv: Boolean by lazy {
try {
val pm = appCtx.packageManager
pm.hasSystemFeature("android.software.leanback") ||
pm.hasSystemFeature("android.hardware.type.television") ||
(appCtx.getSystemService(Context.UI_MODE_SERVICE) as android.app.UiModeManager)
.currentModeType == android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
} catch (_: Exception) { false }
}
private val caps: List<String> by lazy {
val base = mutableListOf("info", "screenshot", "ui_dump",
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
if (isTv) base += "tv" // Kennzeichen: dieses Geraet ist ein Fernseher/Stick
base
}
fun start() {
running = true
@@ -100,7 +113,8 @@ class RvsClient(
val p = JSONObject()
p.put("hostId", config.hostId())
p.put("name", config.displayName())
p.put("os", "Android ${Build.VERSION.RELEASE} (${Build.MODEL})")
val kind = if (isTv) "Fire TV / Android TV" else "Android"
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
send(webSocket, "host_hello", p)
@@ -0,0 +1,32 @@
<!-- Banner-Kachel fuer den Fire-TV-/Android-TV-Launcher (320x180 dp).
TV-Launcher zeigen Banner statt Icons — ohne das fehlt die Kachel. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path
android:fillColor="#0D0D1A"
android:pathData="M0,0h320v180h-320z" />
<!-- Ring (ARIA-Auge) -->
<path
android:fillColor="#3FFF9F"
android:pathData="M74,90 m-34,0 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0 Z M74,90 m-17,0 a17,17 0 1,1 34,0 a17,17 0 1,1 -34,0 Z" />
<!-- Schriftzug "ARIA" als Pfade (keine Font-Abhaengigkeit im Vector) -->
<path
android:fillColor="#E0E0F0"
android:pathData="M140,110 L152,64 L162,64 L174,110 L164,110 L161,99 L153,99 L150,110 Z M155,91 L159,91 L157,82 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M180,110 L180,64 L196,64 A13,13 0 0,1 196,90 L200,110 L190,110 L187,92 L189,92 L189,110 Z M189,72 L189,84 L195,84 A6,6 0 0,0 195,72 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M208,64 L217,64 L217,110 L208,110 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M224,110 L236,64 L246,64 L258,110 L248,110 L245,99 L237,99 L234,110 Z M239,91 L243,91 L241,82 Z" />
<!-- Untertitel-Balken -->
<path
android:fillColor="#3FFF9F"
android:pathData="M140,122 h118 v4 h-118 z" />
</vector>
@@ -29,6 +29,8 @@
android:id="@+id/btnScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="false"
android:text="QR-Code scannen" />
<TextView