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.GetAppList
import pw.binom.rayneovm.shared.AppEntry import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
/** /**
* Foreground service на стороне очков. * Foreground service на стороне очков.
@@ -119,7 +121,18 @@ class VmService : LifecycleService() {
controller.launchGuest(msg.packageName) controller.launchGuest(msg.packageName)
} }
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}") 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 SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
is Shutdown -> { is Shutdown -> {
Log.i(TAG, "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.VmShizukuService
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState 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) private val _frameSource = MutableStateFlow<FrameSource?>(null)
val frameSource: StateFlow<FrameSource?> = _frameSource.asStateFlow() 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 private var activeSource: VirtualDisplayFrameSource? = null
@Volatile private var appContext: Context? = null @Volatile private var appContext: Context? = null
@@ -82,6 +87,7 @@ class VmGlassesController {
return return
} }
try { try {
runCatching { ShizukuVdBinder.stopKillGuard(src.shizukuBinder) }
src.release() src.release()
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "releaseFrameSource failed", t) Log.w(TAG, "releaseFrameSource failed", t)
@@ -90,11 +96,51 @@ class VmGlassesController {
_frameSource.value = null _frameSource.value = null
} }
/** Обновляет состояние экрана. */
fun setState(newState: GlassesScreenState) { fun setState(newState: GlassesScreenState) {
Log.i(TAG, "setState: $newState") Log.i(TAG, "setState: $newState")
_state.value = 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]. * DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast. * Используется для тестирования через adb broadcast.
@@ -108,6 +154,8 @@ class VmGlassesController {
fun launchGuest(packageName: String) { fun launchGuest(packageName: String) {
Log.i(TAG, "launchGuest: $packageName") Log.i(TAG, "launchGuest: $packageName")
setState(GlassesScreenState.Running(packageName)) setState(GlassesScreenState.Running(packageName))
// Курсор появляется по центру экрана — иначе первый клик уйдёт «в никуда».
_cursor.value = CursorPosition(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)
// Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId. // Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
// Без Shizuku acquireFrameSource вернёт null → гость не запустится. // Без Shizuku acquireFrameSource вернёт null → гость не запустится.
@@ -131,6 +179,15 @@ class VmGlassesController {
val pm = ctx2.packageManager val pm = ctx2.packageManager
val launchIntent = pm.getLaunchIntentForPackage(packageName) val launchIntent = pm.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: "" 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( val ok = ShizukuVdBinder.launchActivity(
binder = src.shizukuBinder, binder = src.shizukuBinder,
packageName = packageName, packageName = packageName,
@@ -148,6 +205,7 @@ class VmGlassesController {
fun stopGuest() { fun stopGuest() {
Log.i(TAG, "stopGuest") Log.i(TAG, "stopGuest")
releaseFrameSource() releaseFrameSource()
_cursor.value = null
setState(GlassesScreenState.NoAppSelected) setState(GlassesScreenState.NoAppSelected)
} }
@@ -157,9 +215,9 @@ class VmGlassesController {
companion object { companion object {
private const val TAG = "VmGlassesController" private const val TAG = "VmGlassesController"
private const val VIRTUAL_WIDTH = 640 private const val VIRTUAL_WIDTH = VmDisplay.WIDTH
private const val VIRTUAL_HEIGHT = 480 private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
private const val VIRTUAL_DENSITY = 160 private const val VIRTUAL_DENSITY = VmDisplay.DENSITY
val Local = staticCompositionLocalOf<VmGlassesController> { val Local = staticCompositionLocalOf<VmGlassesController> {
error("VmGlassesController not provided") error("VmGlassesController not provided")
@@ -175,6 +233,9 @@ interface ControllerProvider {
fun provideController(): VmGlassesController fun provideController(): VmGlassesController
} }
/** Позиция курсора мыши в координатах виртуального дисплея гостя. */
data class CursorPosition(val x: Float, val y: Float)
/** /**
* DEBUG broadcast receiver для тестирования через adb. * DEBUG broadcast receiver для тестирования через adb.
* *
@@ -1,5 +1,6 @@
package pw.binom.rayneovm.glasses.ui package pw.binom.rayneovm.glasses.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -15,7 +16,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -41,6 +44,7 @@ fun GlassesScreen(
) { ) {
val state by controller.state.collectAsState() val state by controller.state.collectAsState()
val controllerFrameSource by controller.frameSource.collectAsState() val controllerFrameSource by controller.frameSource.collectAsState()
val cursor by controller.cursor.collectAsState()
val noOp = remember { NoOpFrameSource() } val noOp = remember { NoOpFrameSource() }
val frameSource = controllerFrameSource ?: noOp val frameSource = controllerFrameSource ?: noOp
@@ -72,8 +76,8 @@ fun GlassesScreen(
} }
) )
is GlassesScreenState.Running -> { is GlassesScreenState.Running -> {
// Ничего поверх GL — пользователь видит гостевой контент. // Ничего поверх GL, кроме курсора мыши: FrameSource уже установлен контроллером.
// FrameSource уже установлен контроллером (launchGuest → controller.frameSource). cursor?.let { CursorOverlay(it.x, it.y) }
} }
is GlassesScreenState.Error -> StatusOverlay( is GlassesScreenState.Error -> StatusOverlay(
title = "Ошибка", 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 @Composable
private fun StatusOverlay(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) { private fun StatusOverlay(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) {
val baseModifier = Modifier.fillMaxSize().background(Color.Black).padding(32.dp) val baseModifier = Modifier.fillMaxSize().background(Color.Black).padding(32.dp)
@@ -18,8 +18,11 @@ import pw.binom.rayneovm.shared.AppCrashedEvent
import pw.binom.rayneovm.shared.AppEntry import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.AppLaunchedAck import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.GetAppList import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.VmMessage import pw.binom.rayneovm.shared.VmMessage
@@ -159,6 +162,18 @@ class VmPhoneController(
} }
} }
/** Позиция курсора мыши в координатах VirtualDisplay — очки рисуют его в обоих глазах. */
suspend fun moveCursor(x: Float, y: Float) = send(CursorMove(x, y))
/** Клик мыши в текущей позиции курсора на очках. */
suspend fun clickCursor() = send(CursorClick)
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
suspend fun sendKey(keyCode: Int) {
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_UP))
}
/** /**
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE. * Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager. * Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Mouse
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -25,10 +26,11 @@ import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.VmPhoneController import pw.binom.rayneovm.phone.protocol.VmPhoneController
/** /**
* Главный экран приложения: NavigationBar с двумя табами. * Главный экран приложения: NavigationBar с тремя табами.
* *
* - «Очки» — статус связи и состояние GlassesApp * - «Очки» — статус связи и состояние GlassesApp
* - «Приложения» — список приложений для запуска * - «Приложения» — список приложений для запуска
* - «Пульт» — тачпад-мышь и медиа-кнопки для гостя
*/ */
@Composable @Composable
fun HomeScreen( fun HomeScreen(
@@ -63,6 +65,7 @@ fun HomeScreen(
onRequestMercuryPermissions = onRequestMercuryPermissions, onRequestMercuryPermissions = onRequestMercuryPermissions,
) )
Tab.APPS -> AppsListScreen(controller = controller) Tab.APPS -> AppsListScreen(controller = controller)
Tab.REMOTE -> RemoteScreen(controller = controller)
} }
} }
} }
@@ -72,4 +75,5 @@ fun HomeScreen(
private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) { private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
GLASSES("Очки", Icons.Default.Settings), GLASSES("Очки", Icons.Default.Settings),
APPS("Приложения", Icons.Default.Apps), APPS("Приложения", Icons.Default.Apps),
REMOTE("Пульт", Icons.Default.Mouse),
} }
@@ -0,0 +1,149 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.VmDisplay
// android.view.KeyEvent-коды. Здесь обычные числа, чтобы не тянуть android-зависимости в UI-тесты.
private const val KEYCODE_BACK = 4
private const val KEYCODE_MEDIA_PLAY = 126
private const val KEYCODE_MEDIA_PAUSE = 127
/**
* Пульт управления гостевым приложением на очках:
* - тачпад-мышь: движение пальца → курсор (отыгрывается на очках в обоих глазах),
* тап по тачпаду → клик в текущей позиции курсора;
* - кнопки «Назад», «Плей», «Пауза»;
* - «Клавиатура» — заглушка (сделаем позже).
*/
@Composable
fun RemoteScreen(
controller: VmPhoneController,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
// Позиция курсора в координатах VirtualDisplay гостя.
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) }
fun click() {
scope.launch { controller.clickCursor() }
}
Column(
modifier = modifier.fillMaxSize().background(Color.Black).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF141414))
.pointerInput(Unit) {
detectTapGestures(onTap = { click() })
}
.pointerInput(Unit) {
detectDragGestures { change, drag ->
// Относительное движение: полный проход по тачпаду = весь экран гостя.
val gainX = VmDisplay.WIDTH / size.width.toFloat()
val gainY = VmDisplay.HEIGHT / size.height.toFloat()
cursor = Offset(
(cursor.x + drag.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
(cursor.y + drag.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
change.consume()
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = "Тачпад: ведите пальцем — курсор на очках.\nТап — клик по курсору.",
color = Color(0xFF666666),
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
// Мини-индикатор положения курсора (для обратной связи на телефоне).
Canvas(modifier = Modifier.fillMaxSize()) {
val cx = cursor.x / VmDisplay.WIDTH * size.width
val cy = cursor.y / VmDisplay.HEIGHT * size.height
drawCircle(color = Color(0xFFFFEB3B), radius = 8f, center = Offset(cx, cy))
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RemoteButton(Icons.AutoMirrored.Filled.ArrowBack, "Назад", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_BACK) }
}
RemoteButton(Icons.Default.PlayArrow, "Плей", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_MEDIA_PLAY) }
}
RemoteButton(Icons.Default.Pause, "Пауза", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_MEDIA_PAUSE) }
}
// TODO: клавиатура — отдельная задача.
RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f), enabled = false) {}
}
}
}
@Composable
private fun RemoteButton(
icon: ImageVector,
label: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
contentPadding = PaddingValues(vertical = 12.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = label)
Text(label, fontSize = 11.sp)
}
}
}
@@ -0,0 +1,14 @@
package pw.binom.rayneovm.shared
/**
* Параметры виртуального дисплея, на котором запускается гостевое приложение.
*
* Должны совпадать на телефоне и очках: телефон в этих координатах шлёт позицию курсора
* ([CursorMove]) и тач-события ([TouchAction]), очки в этих же координатах создают
* VirtualDisplay и рисуют курсор в обоих глазах.
*/
object VmDisplay {
const val WIDTH = 640
const val HEIGHT = 480
const val DENSITY = 160
}
@@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable
* (настраивается в [VmProtocol]). * (настраивается в [VmProtocol]).
* *
* Потоки: * Потоки:
* - Phone → Glasses: [TouchEvent], [KeyEvent], [LaunchAppCommand], [SetAudioMode], [Shutdown] * - Phone → Glasses: [TouchEvent], [KeyEvent], [CursorMove], [CursorClick], [LaunchAppCommand], [SetAudioMode], [Shutdown]
* - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck] * - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck]
* - Двунаправленные: [Ping], [Pong] * - Двунаправленные: [Ping], [Pong]
*/ */
@@ -55,6 +55,30 @@ data class KeyEvent(
} }
} }
/**
* Позиция виртуального курсора мыши в координатах виртуального дисплея гостя
* (0..[VmDisplay.WIDTH]-1, 0..[VmDisplay.HEIGHT]-1).
*
* Очки рисуют курсор в обоих глазах и запоминают позицию как цель для [CursorClick].
*/
@Serializable
@SerialName("cursor_move")
data class CursorMove(
val x: Float,
val y: Float,
) : VmMessage
/**
* Клик виртуальной мыши в текущей позиции курсора ([CursorMove]).
*
* Отдельных DOWN/UP нет намеренно: очки инжектят пару DOWN+UP одной транзакцией
* (см. `ShizukuVdBinder.tap`). Двумя независимыми сообщениями порядок не гарантирован:
* тач-поток у гостя разбалансируется («залипший» палец) и клики перестают работать.
*/
@Serializable
@SerialName("cursor_click")
data object CursorClick : VmMessage
@Serializable @Serializable
@SerialName("launch_app") @SerialName("launch_app")
data class LaunchAppCommand( data class LaunchAppCommand(
@@ -46,6 +46,28 @@ class VmProtocolTest {
assertEquals(msg, decoded) assertEquals(msg, decoded)
} }
@Test
fun `CursorMove roundtrip`() {
val msg = CursorMove(x = 123.5f, y = 456.25f)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<CursorMove>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `CursorClick roundtrip`() {
val decoded = VmProtocol.decode<CursorClick>(VmProtocol.encode(CursorClick))
assertEquals(CursorClick, decoded)
}
@Test
fun `decodeAny for CursorMove uses type discriminator`() {
val msg = VmProtocol.decodeAny("""{"type":"cursor_move","x":10.5,"y":20.25}""")
assertTrue(msg is CursorMove)
assertEquals(10.5f, msg.x)
assertEquals(20.25f, msg.y)
}
@Test @Test
fun `GlassesStatus roundtrip`() { fun `GlassesStatus roundtrip`() {
val msg = GlassesStatus( val msg = GlassesStatus(