diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 20bf8df..4450fa2 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -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") diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt index ceeaaf7..7b9ef8e 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt @@ -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(null) val frameSource: StateFlow = _frameSource.asStateFlow() + /** Позиция виртуального курсора мыши (координаты VirtualDisplay). null — курсор не показываем. */ + private val _cursor = MutableStateFlow(null) + val cursor: StateFlow = _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 { 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. * diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt index 3256574..1ea36af 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt @@ -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) diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index d540354..bf40c94 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -18,8 +18,11 @@ import pw.binom.rayneovm.shared.AppCrashedEvent import pw.binom.rayneovm.shared.AppEntry import pw.binom.rayneovm.shared.AppListResponse 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.GlassesStatus +import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.SetAudioMode 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. * Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager. diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt index a0c4f58..93ec546 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Mouse import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -25,10 +26,11 @@ import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.protocol.VmPhoneController /** - * Главный экран приложения: NavigationBar с двумя табами. + * Главный экран приложения: NavigationBar с тремя табами. * * - «Очки» — статус связи и состояние GlassesApp * - «Приложения» — список приложений для запуска + * - «Пульт» — тачпад-мышь и медиа-кнопки для гостя */ @Composable fun HomeScreen( @@ -63,6 +65,7 @@ fun HomeScreen( onRequestMercuryPermissions = onRequestMercuryPermissions, ) 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) { GLASSES("Очки", Icons.Default.Settings), APPS("Приложения", Icons.Default.Apps), + REMOTE("Пульт", Icons.Default.Mouse), } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt new file mode 100644 index 0000000..b846c5a --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt @@ -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) + } + } +} diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmDisplay.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmDisplay.kt new file mode 100644 index 0000000..c749549 --- /dev/null +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmDisplay.kt @@ -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 +} diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index 121421e..83c6406 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * (настраивается в [VmProtocol]). * * Потоки: - * - Phone → Glasses: [TouchEvent], [KeyEvent], [LaunchAppCommand], [SetAudioMode], [Shutdown] + * - Phone → Glasses: [TouchEvent], [KeyEvent], [CursorMove], [CursorClick], [LaunchAppCommand], [SetAudioMode], [Shutdown] * - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck] * - Двунаправленные: [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 @SerialName("launch_app") data class LaunchAppCommand( diff --git a/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt b/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt index dd8321d..3f30743 100644 --- a/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt +++ b/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt @@ -46,6 +46,28 @@ class VmProtocolTest { 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(encoded) + assertEquals(msg, decoded) + } + + @Test + fun `CursorClick roundtrip`() { + val decoded = VmProtocol.decode(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 fun `GlassesStatus roundtrip`() { val msg = GlassesStatus(