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
@@ -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]).
*
* Потоки:
* - 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(
@@ -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<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
fun `GlassesStatus roundtrip`() {
val msg = GlassesStatus(