phone: third «Пульт» tab with on-glasses cursor; atomic click injection

- shared: VmDisplay (guest 640x480 coordinate space), CursorMove and
  CursorClick messages (CursorClick replaces the DOWN/UP pair — see below).
- glasses: cursor state + Canvas overlay drawn in both eyes, clickCursor
  injects one atomic tap at the cursor, sendKey for Back/Play/Pause.
- phone: RemoteScreen — touchpad (relative drag moves the cursor, tap
  clicks), «Назад»/«Плей»/«Пауза» buttons, «Клавиатура» stub (TODO).
- The old CursorButton(DOWN)/CursorButton(UP) pair was removed: the two
  messages were injected concurrently and could arrive out of order,
  breaking the guest's touch stream so clicks worked only once.
- tests: CursorMove/CursorClick roundtrips.
This commit is contained in:
2026-09-25 18:20:56 +03:00
parent 03f1d7dcc7
commit 3fce3eb3ca
9 changed files with 338 additions and 8 deletions
@@ -32,6 +32,8 @@ import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
/**
* Foreground service на стороне очков.
@@ -119,7 +121,18 @@ class VmService : LifecycleService() {
controller.launchGuest(msg.packageName)
}
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}")
is VmKeyEvent -> Log.d(TAG, "Key: ${msg.keyCode}")
is CursorMove -> {
Log.d(TAG, "CursorMove: ${msg.x},${msg.y}")
controller.moveCursor(msg.x, msg.y)
}
is CursorClick -> {
Log.d(TAG, "CursorClick")
controller.clickCursor()
}
is VmKeyEvent -> {
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
controller.sendKey(msg.keyCode, msg.action)
}
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
is Shutdown -> {
Log.i(TAG, "Shutdown")
@@ -21,6 +21,7 @@ import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.VmDisplay
/**
* Контроллер состояния экрана очков и кадров.
@@ -35,6 +36,10 @@ class VmGlassesController {
private val _frameSource = MutableStateFlow<FrameSource?>(null)
val frameSource: StateFlow<FrameSource?> = _frameSource.asStateFlow()
/** Позиция виртуального курсора мыши (координаты VirtualDisplay). null — курсор не показываем. */
private val _cursor = MutableStateFlow<CursorPosition?>(null)
val cursor: StateFlow<CursorPosition?> = _cursor.asStateFlow()
private var activeSource: VirtualDisplayFrameSource? = null
@Volatile private var appContext: Context? = null
@@ -82,6 +87,7 @@ class VmGlassesController {
return
}
try {
runCatching { ShizukuVdBinder.stopKillGuard(src.shizukuBinder) }
src.release()
} catch (t: Throwable) {
Log.w(TAG, "releaseFrameSource failed", t)
@@ -90,11 +96,51 @@ class VmGlassesController {
_frameSource.value = null
}
/** Обновляет состояние экрана. */
fun setState(newState: GlassesScreenState) {
Log.i(TAG, "setState: $newState")
_state.value = newState
}
/**
* Перемещает виртуальный курсор мыши. Очки рисуют его в обоих глазах, а [clickCursor]
* использует последнюю позицию как точку клика.
*/
fun moveCursor(x: Float, y: Float) {
_cursor.value = CursorPosition(
x = x.coerceIn(0f, VmDisplay.WIDTH - 1f),
y = y.coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
}
/**
* Клик мыши в текущей позиции курсора: инжектим гостю пару DOWN+UP ОДНОЙ транзакцией.
*
* Раньше DOWN и UP уходили отдельными сообщениями/корутинами — порядок не гарантирован,
* тач-поток у гостя разбалансировался и клик срабатывал ровно один раз.
*/
fun clickCursor() {
val src = activeSource ?: return
val pos = _cursor.value ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
testScope.launch {
val ok = ShizukuVdBinder.tap(binder, displayId, pos.x, pos.y)
if (!ok) Log.w(TAG, "clickCursor: tap failed (displayId=$displayId)")
}
}
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
fun sendKey(keyCode: Int, action: Int) {
val src = activeSource ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
testScope.launch {
val ok = ShizukuVdBinder.injectKeyEvent(binder, displayId, keyCode, action)
if (!ok) Log.w(TAG, "sendKey: injectKeyEvent failed (keyCode=$keyCode)")
}
}
/**
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast.
@@ -108,6 +154,8 @@ class VmGlassesController {
fun launchGuest(packageName: String) {
Log.i(TAG, "launchGuest: $packageName")
setState(GlassesScreenState.Running(packageName))
// Курсор появляется по центру экрана — иначе первый клик уйдёт «в никуда».
_cursor.value = CursorPosition(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)
// Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
// Без Shizuku acquireFrameSource вернёт null → гость не запустится.
@@ -131,6 +179,15 @@ class VmGlassesController {
val pm = ctx2.packageManager
val launchIntent = pm.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: ""
// Пока гость работает, держим «страж»: Settings.Secure.top_app = наш пакет.
// Mercury-лаунчер по первой же проверке в forceStopApp (pkgName == mCurrentTopPkg)
// отменяет убийство. Иначе нас/гостя force-стопит через ~2 c после смены top-app.
// Подробный разбор — в ShizukuVdBinder.startKillGuard.
if (!ShizukuVdBinder.startKillGuard(src.shizukuBinder, ctx2.packageName)) {
Log.w(TAG, "launchGuest: не удалось запустить Mercury kill guard")
}
val ok = ShizukuVdBinder.launchActivity(
binder = src.shizukuBinder,
packageName = packageName,
@@ -148,6 +205,7 @@ class VmGlassesController {
fun stopGuest() {
Log.i(TAG, "stopGuest")
releaseFrameSource()
_cursor.value = null
setState(GlassesScreenState.NoAppSelected)
}
@@ -157,9 +215,9 @@ class VmGlassesController {
companion object {
private const val TAG = "VmGlassesController"
private const val VIRTUAL_WIDTH = 640
private const val VIRTUAL_HEIGHT = 480
private const val VIRTUAL_DENSITY = 160
private const val VIRTUAL_WIDTH = VmDisplay.WIDTH
private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
private const val VIRTUAL_DENSITY = VmDisplay.DENSITY
val Local = staticCompositionLocalOf<VmGlassesController> {
error("VmGlassesController not provided")
@@ -175,6 +233,9 @@ interface ControllerProvider {
fun provideController(): VmGlassesController
}
/** Позиция курсора мыши в координатах виртуального дисплея гостя. */
data class CursorPosition(val x: Float, val y: Float)
/**
* DEBUG broadcast receiver для тестирования через adb.
*
@@ -1,5 +1,6 @@
package pw.binom.rayneovm.glasses.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -15,7 +16,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -41,6 +44,7 @@ fun GlassesScreen(
) {
val state by controller.state.collectAsState()
val controllerFrameSource by controller.frameSource.collectAsState()
val cursor by controller.cursor.collectAsState()
val noOp = remember { NoOpFrameSource() }
val frameSource = controllerFrameSource ?: noOp
@@ -72,8 +76,8 @@ fun GlassesScreen(
}
)
is GlassesScreenState.Running -> {
// Ничего поверх GL — пользователь видит гостевой контент.
// FrameSource уже установлен контроллером (launchGuest → controller.frameSource).
// Ничего поверх GL, кроме курсора мыши: FrameSource уже установлен контроллером.
cursor?.let { CursorOverlay(it.x, it.y) }
}
is GlassesScreenState.Error -> StatusOverlay(
title = "Ошибка",
@@ -83,6 +87,30 @@ fun GlassesScreen(
}
}
/**
* Курсор мыши поверх GL-слоя.
*
* Экран физически 1280×480: левая половина — левый глаз, правая — правый.
* Точку гостя (x, y) рисуем в обеих половинах на одном и том же y.
*/
@Composable
private fun CursorOverlay(x: Float, y: Float) {
Canvas(modifier = Modifier.fillMaxSize()) {
val half = size.width / 2f
val radius = 10f
for (cx in floatArrayOf(x, x + half)) {
val center = Offset(cx, y)
drawCircle(color = Color(0xFFFFEB3B), radius = radius, center = center)
drawCircle(
color = Color(0xCC000000),
radius = radius,
center = center,
style = Stroke(width = 3f),
)
}
}
}
@Composable
private fun StatusOverlay(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) {
val baseModifier = Modifier.fillMaxSize().background(Color.Black).padding(32.dp)