Feat: TV remote control via D-pad focus navigation using AccessibilityService

This commit is contained in:
yuanyuanxiang
2026-06-22 17:57:51 +02:00
parent 45553ec5b6
commit 218ee4f43d
22 changed files with 631 additions and 67 deletions

View File

@@ -21,6 +21,7 @@ import android.provider.Settings
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import android.widget.Toast
class CaptureService : Service() {
@@ -39,6 +40,14 @@ class CaptureService : Service() {
// CS-01 fix: 通过 idrHandler.post 序列化到主线程,避免 TOCTOU 竞争
@Volatile var instance: CaptureService? = null
@JvmStatic
fun onNativeStatus(msg: String) {
val svc = instance ?: return
svc.idrHandler.post {
Toast.makeText(svc, msg, Toast.LENGTH_LONG).show()
}
}
@JvmStatic
fun requestIdr() {
val svc = instance ?: return
@@ -100,9 +109,16 @@ class CaptureService : Service() {
super.onCreate()
instance = this
createNotificationChannel()
// CS-06: start with DATA_SYNC type only; upgrade to MEDIA_PROJECTION in
// onStartCommand when the token is actually available. On Android 14+
// (targetSdk 34+) calling startForeground(MEDIA_PROJECTION) without
// immediately pairing it with getMediaProjection() causes the system to
// kill the service after a ~5-second grace period — the root cause of the
// 3-9 s disconnect seen on Google TV when no screen-capture permission
// was granted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, buildNotification())
}
@@ -124,12 +140,23 @@ class CaptureService : Service() {
Log.i(TAG, "CaptureService: server=$ip:$port device=$model res=$res")
// YB-01 fix: so 库加载失败是 Error不被 catch(Exception) 捕获,需单独处理
// ExceptionInInitializerError 包裹 UnsatisfiedLinkError首次访问 object 时触发)
val initRet: Int
try {
YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "")
initRet = YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "")
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "Native library load failed: $e")
stopSelf()
return START_NOT_STICKY
} catch (e: ExceptionInInitializerError) {
Log.e(TAG, "Native library init failed: ${e.cause}")
stopSelf()
return START_NOT_STICKY
}
if (initRet != 0) {
Log.e(TAG, "nativeInit failed: ret=$initRet")
stopSelf()
return START_NOT_STICKY
}
val projResult = intent.getIntExtra(EXTRA_PROJECTION_RESULT, -1)
@@ -139,12 +166,22 @@ class CaptureService : Service() {
@Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_PROJECTION_DATA)
if (projResult == android.app.Activity.RESULT_OK && projData != null) {
// Android 14+: upgrade foreground type to MEDIA_PROJECTION in the same
// start command that calls getMediaProjection(), as required by API 34+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
}
val pm = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val mp = pm.getMediaProjection(projResult, projData)
mediaProjection = mp
// CS-05 fix: 分辨率为 0 时不启动采集,避免 MediaCodec 配置崩溃
if (sw > 0 && sh > 0) {
startCapture(mp, sw, sh)
try {
startCapture(mp, sw, sh)
} catch (e: Exception) {
Log.e(TAG, "Screen capture failed: $e — running without capture")
}
} else {
Log.e(TAG, "Invalid screen resolution ${sw}x${sh}, skipping capture")
}
@@ -223,9 +260,14 @@ class CaptureService : Service() {
// Android 14+ requires callback registered before createVirtualDisplay()
mp.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped")
Log.i(TAG, "MediaProjection stopped — switching to dataSync foreground type")
stopCapture()
stopSelf()
// Android 14+: system auto-kills a foreground service whose mediaProjection
// is revoked. Re-declare as dataSync-only to survive without screen capture.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
}
}, null)

View File

@@ -14,7 +14,9 @@ import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/**
* AccessibilityService that injects touch gestures and global key actions
@@ -88,6 +90,22 @@ class ControlService : AccessibilityService() {
// ── Main-thread handler ───────────────────────────────────────────────
private val handler = Handler(Looper.getMainLooper())
// ── TV (Google TV / Android TV) D-pad mode ───────────────────────────
// Activated when FEATURE_LEANBACK is present. Mouse events are translated
// to accessibility focus navigation — no touchscreen or root required.
private var isTV = false
private var tvLastEncX = -1f
private var tvLastEncY = -1f
private var tvAccumDX = 0f
private var tvAccumDY = 0f
private val TV_STEP = 40f // encoding-space pixels per D-pad step
// Touch-drag disambiguation: fire click on LBUTTONUP only if finger barely moved
private var tvTouchDown = false
private var tvTouchDownX = 0f
private var tvTouchDownY = 0f
private var tvTouchMoved = false
private val TV_TAP_THRESHOLD = 15f // encoding pixels — below this = tap, above = drag
// ── Left-button drag accumulator ─────────────────────────────────────
private var lbuttonDown = false
private var gestureStart = 0L
@@ -100,6 +118,8 @@ class ControlService : AccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
isTV = packageManager.hasSystemFeature("android.software.leanback")
if (isTV) Log.i(TAG, "TV mode: D-pad navigation enabled")
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_USER_PRESENT)
@@ -127,6 +147,7 @@ class ControlService : AccessibilityService() {
// Event routing (runs on main thread)
private fun handleEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
if (isTV) { handleEventTV(message, wParam, ptX, ptY); return }
// Scale encoding-space coords to physical screen
val x = if (encW > 0) ptX.toFloat() * physW / encW else ptX.toFloat()
val y = if (encH > 0) ptY.toFloat() * physH / encH else ptY.toFloat()
@@ -265,6 +286,119 @@ class ControlService : AccessibilityService() {
if (!ok) Log.e(TAG, "dispatchGesture returned false (service not ready?)")
}
// ─────────────────────────────────────────────────────────────────────
// TV D-pad mode — translates mouse events to accessibility focus actions
private fun handleEventTV(message: Int, wParam: Long, ptX: Int, ptY: Int) {
when (message) {
WM_MOUSEMOVE -> {
if (tvLastEncX >= 0) {
tvAccumDX += ptX - tvLastEncX
tvAccumDY += ptY - tvLastEncY
while (tvAccumDX > TV_STEP) { tvMoveFocus(View.FOCUS_RIGHT); tvAccumDX -= TV_STEP }
while (tvAccumDX < -TV_STEP) { tvMoveFocus(View.FOCUS_LEFT); tvAccumDX += TV_STEP }
while (tvAccumDY > TV_STEP) { tvMoveFocus(View.FOCUS_DOWN); tvAccumDY -= TV_STEP }
while (tvAccumDY < -TV_STEP) { tvMoveFocus(View.FOCUS_UP); tvAccumDY += TV_STEP }
}
if (tvTouchDown) {
val dx = ptX - tvTouchDownX
val dy = ptY - tvTouchDownY
if (dx * dx + dy * dy > TV_TAP_THRESHOLD * TV_TAP_THRESHOLD) tvTouchMoved = true
}
tvLastEncX = ptX.toFloat()
tvLastEncY = ptY.toFloat()
}
WM_LBUTTONDOWN -> {
tvTouchDown = true
tvTouchDownX = ptX.toFloat()
tvTouchDownY = ptY.toFloat()
tvTouchMoved = false
tvAccumDX = 0f
tvAccumDY = 0f
tvLastEncX = ptX.toFloat()
tvLastEncY = ptY.toFloat()
}
WM_LBUTTONUP -> {
if (tvTouchDown && !tvTouchMoved) tvClick()
tvTouchDown = false
}
WM_LBUTTONDBLCLK -> tvClick()
WM_RBUTTONDOWN -> performGlobalAction(GLOBAL_ACTION_BACK)
WM_MOUSEWHEEL -> {
val delta = (wParam.toInt() ushr 16).toShort().toInt()
tvScroll(delta < 0)
}
WM_KEYDOWN, WM_SYSKEYDOWN -> {
val vk = wParam.toInt() and 0xFFFF
when (vk) {
0x25 -> tvMoveFocus(View.FOCUS_LEFT) // VK_LEFT
0x26 -> tvMoveFocus(View.FOCUS_UP) // VK_UP
0x27 -> tvMoveFocus(View.FOCUS_RIGHT) // VK_RIGHT
0x28 -> tvMoveFocus(View.FOCUS_DOWN) // VK_DOWN
0x0D -> tvClick() // VK_RETURN
else -> handleKeyDown(vk)
}
}
}
}
@Suppress("DEPRECATION")
private fun tvMoveFocus(direction: Int) {
val root = rootInActiveWindow ?: return
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (focused == null) {
val first = findFirstFocusable(root)
first?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
first?.recycle()
} else {
val next = focused.focusSearch(direction)
next?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
focused.recycle()
next?.recycle()
}
root.recycle()
}
@Suppress("DEPRECATION")
private fun tvClick() {
val root = rootInActiveWindow ?: return
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (focused != null) {
focused.performAction(AccessibilityNodeInfo.ACTION_CLICK)
focused.recycle()
} else {
Log.w(TAG, "tvClick: no focused node")
}
root.recycle()
}
@Suppress("DEPRECATION")
private fun tvScroll(forward: Boolean) {
val root = rootInActiveWindow ?: return
val action = if (forward) AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
else AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (focused == null || !focused.performAction(action)) root.performAction(action)
focused?.recycle()
root.recycle()
}
// Walk accessibility tree depth-first, return first visible focusable node (caller must recycle)
@Suppress("DEPRECATION")
private fun findFirstFocusable(node: AccessibilityNodeInfo): AccessibilityNodeInfo? {
if (node.isFocusable && node.isVisibleToUser) return AccessibilityNodeInfo.obtain(node)
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val hit = findFirstFocusable(child)
child.recycle()
if (hit != null) return hit
}
return null
}
private fun handleKeyDown(vk: Int) {
when (vk) {
0x08, 0x1B -> performGlobalAction(GLOBAL_ACTION_BACK) // Backspace / Esc