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:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId 'de.hackersoft.ariaagent'
|
applicationId 'de.hackersoft.ariaagent'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||||
versionCode 7
|
versionCode 8
|
||||||
versionName '0.0.0.7'
|
versionName '0.0.0.8'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
// 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. -->
|
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:banner="@drawable/banner_tv"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
@@ -28,6 +35,9 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<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>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
+102
-3
@@ -5,6 +5,7 @@ import android.accessibilityservice.GestureDescription
|
|||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
import android.view.accessibility.AccessibilityEvent
|
import android.view.accessibility.AccessibilityEvent
|
||||||
import android.view.accessibility.AccessibilityNodeInfo
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
@@ -148,19 +149,76 @@ class AriaAccessibilityService : AccessibilityService() {
|
|||||||
return if (node.isEditable && r.contains(x, y)) node else null
|
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 {
|
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
|
"back", "zurueck", "zurück" -> GLOBAL_ACTION_BACK
|
||||||
"home", "start", "startseite" -> GLOBAL_ACTION_HOME
|
"home", "start", "startseite" -> GLOBAL_ACTION_HOME
|
||||||
"recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS
|
"recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS
|
||||||
"notifications", "benachrichtigungen" -> GLOBAL_ACTION_NOTIFICATIONS
|
"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")
|
return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt")
|
||||||
else errMsg("Taste '$name' fehlgeschlagen")
|
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 =
|
private fun okMsg(m: String): JSONObject =
|
||||||
JSONObject().put("ok", true).put("result", JSONObject().put("message", m))
|
JSONObject().put("ok", true).put("result", JSONObject().put("message", m))
|
||||||
|
|
||||||
@@ -172,5 +230,46 @@ class AriaAccessibilityService : AccessibilityService() {
|
|||||||
var instance: AriaAccessibilityService? = null
|
var instance: AriaAccessibilityService? = null
|
||||||
|
|
||||||
val available: Boolean get() = instance != 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))
|
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 &&
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
@@ -175,5 +182,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
statusView.text = "Status: ${AgentService.status} $dot"
|
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()
|
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,23 @@ class RvsClient(
|
|||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
private var pingThread: Thread? = null
|
private var pingThread: Thread? = null
|
||||||
|
|
||||||
private val caps = listOf(
|
/** Fire TV / Android TV? Dann gibt es keinen Touchscreen, dafuer D-Pad. */
|
||||||
"info", "screenshot", "ui_dump",
|
private val isTv: Boolean by lazy {
|
||||||
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
|
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() {
|
fun start() {
|
||||||
running = true
|
running = true
|
||||||
@@ -100,7 +113,8 @@ class RvsClient(
|
|||||||
val p = JSONObject()
|
val p = JSONObject()
|
||||||
p.put("hostId", config.hostId())
|
p.put("hostId", config.hostId())
|
||||||
p.put("name", config.displayName())
|
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("caps", JSONArray(caps))
|
||||||
p.put("control", config.controlEnabled)
|
p.put("control", config.controlEnabled)
|
||||||
send(webSocket, "host_hello", p)
|
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:id="@+id/btnScan"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
android:text="QR-Code scannen" />
|
android:text="QR-Code scannen" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
Reference in New Issue
Block a user