#1,#3,#4+#5 keyboards: Binocular Compose, cursor watchdog 3s, SetText + VmNoopIme + sendText

This commit is contained in:
2026-09-27 11:18:52 +03:00
parent 3ac76daca1
commit 65f3f5d228
11 changed files with 238 additions and 94 deletions
+56 -52
View File
@@ -1,73 +1,77 @@
# BLOCKERS ночной сессии 2026-09-27 # Blockers
## B1 — Нет общей L3-сети между телефоном и очками для проверки ON_PHONE end-to-end Сюда пишутся только те задачи/проблемы, в которых застряли. Каждая запись — задача,
что сделано, в чём стена, что проверено, варианты обхода. Записи ведутся по правилам
ночной сессии (см. `TASKS.md`, секция «Правила ночной сессии»).
**Задача:** #2 (проброс звука VirtualDisplay → телефон). ---
**Что сделано:** ## B-1 · ShizukuProvisioner: «сетевой» путь приводит к лаунчу не той активности
- `AudioBridge` + `GlassesAudioCapture` + WebSocket-аудио-сервер `/vm-audio` (порт 8081) на очках + **Когда замечено:** 2026-09-27, после серии попыток запуска `free.zona` через debug-broadcast
`AudioPlaybackManager` на телефоне + UI-переключатель «Звук» в `GlassesStatusScreen`. (`DEBUG_LAUNCH`).
- **MediaProjection + AudioPlaybackCapture подтверждены работающими на очках в автономном debug-режиме
`DEBUG_AUDIO_PIPE`** (preview без WebSocket-канала):
```
TestTone: test tone start: 44100 Hz stereo, 10s
GlassesAudioCapture: capture: bytes=1310720 rms=5629 ← захват системного звука работает!
TestTone: test tone done
GlassesAudioCapture: capture: bytes=1474560 rms=0 ← тишина после теста
```
RMS 5629 совпадает с теоретическим (~5657 для амплитуды 8000) → мы реально ловим 440 Гц.
- Тестовый тон → динамик очков → AudioPlaybackCapture на тех же очках → доказано.
- Подача `Recorder Audio` + `PROJECT_MEDIA=allow` через `AdbController`/`appops` — работает.
**В чём стена:** WebSocket-канал (AudioPath между очками и телефоном) требует общую L3-сеть. На данный момент: **Симптом:** В логах `ShizukuVdService: launchActivity pkg=com.ffalconxr.mercury.launcher
cls=com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity displayId=2` повторяется
каждые 5 секунд, постоянно.
- Phone: `192.168.76.121/22 dev wlan0 → SSID "O2 5"`. **Что сделано:** Broadcast уходил на очки через `adb shell am broadcast -n pw.binom.rayneovm.glasses/.debug.GlassesDebugReceiver -a pw.binom.rayneovm.glasses.action.DEBUG_LAUNCH --es pkg 'free.zona' --es cls 'ru.zona.app.android.MainActivity' --es displayId '2'`.
- Glasses: подключены к тому же `O2 5` через supplicant (CONNECTED), **но без IP на wlan0** (`network is unreachable`).
Просто очкам DHCP не отдал адрес (по какой-то причине).
- mDNS работает только в broadcast-домене одной L2/L3 сети.
Без общего IP между устройствами WebSocket `/vm-audio` не пройдёт — `AudioPlaybackManager` на телефоне подключиться не сможет. Сам захват звука на очках доказан, но отправка байтов по WS до телефона — нет. **Стена:** Не определено, откуда берётся `pkg=com.ffalconxr.mercury.launcher/.NetConfigActivity`.
Возможные источники:
- DebugReceiver неправильно парсит extras или имеет default pkg.
- `ensureNetworking()`/Wi-Fi fallback в `VmService` имеет retry-loop.
- Какое-то system-job мерцания очков стартует NetConfigActivity.
**Что проверял:** **Что НЕ проверял:** Тяжёлого анализа не делал — ловится на пол-пути. Не блокирует
другие задачи: #4 (клавиатура), #1 (биноокуляр), #3 (курсор), #5 (Accessibility) — от него
не зависят. Лаунчерт-проблема — отдельная задача.
- `service call adb 4 i32 1 s16 "O2 5"` + `settings put global adb_wifi_enabled 1` — wireless-adb поднимается. **Обход:** Не использовать `DEBUG_LAUNCH`. Для тестов Zona запускать с телефона через штатный
- Bluetooth-канал (Mercury) работает (BLE-команды идут). Но для WebSocket audio не применим (пропускная способность BLE всё-таки ограничена для 44.1 кГц стерео, что добавляет сложностей). контроллер (`controller.startGuest(packageName)` при подключённом канале), либо руками
- ping телефон ↔ очки = `network is unreachable` от очков в сторону телефона (wlan0 не получил IP). через `adb shell am start -n free.zona/ru.zona.app.android.MainActivity -d 0:2`.
**Обход (нужна помощь пользователя):** ---
1. **Самый прямой:** пользователь включает Wi-Fi-хотспот на телефоне (через UI, ~30 секунд). ## B-2 · rayneo-mercury беспроводной `enableWirelessAdb` через BLE — не работает на RayNeo X3 Pro
- SSID/пароль любые.
- Телефон и очки → обе в одну сеть, получают IP от телефона.
- Очки уже умеют авто-коннектиться к сохранённой сети (Caffeine Portable или любая).
2. **Альтернатива:** пользователь подключает очки к USB-tethering'у с телефона (если dev'ы возможно на устройстве).
3. **Самый сложный:** настроить прямой Wi-Fi Direct между телефоном и очками — `cmd wifi p2p ...` потребует более глубокой интеграции.
После появления общей сети проверить: **Когда выяснено:** 2026-09-27, аудит RayNeoLauncher по smali (`adb pull /system/app/RayNeoLauncher`).
```bash **Симптом:**
# на телефоне:
adb shell am start -n pw.binom.rayneovm.phone/.MainActivity # поднять UI
# UI → «Очки» → тумблер «Звук на телефоне».
# В логах телефона должно быть:
# AudioPlaybackManager: audio connected to ws://<glasses_ip>:8081/vm-audio
# AudioPlaybackManager: playing: bytes=… rms=…
# В логах очков:
# GlassesAudioCapture: capture: bytes=… rms=…
# VmService: sender: clients=1 bytes=…
``` ```
E/MercuryResponse: Can not resolve(uri = ble.request.debug), check the request map in Message.kt
```
При попытке поднять wireless-adb через `RayNeoMercury.enableWirelessAdb()`. Команда молча
игнорируется на очках.
**Статус:** #2 прогресс в работе, ядро (MediaProjection + AudioPlaybackCapture) доказано рабочим. Только финальная L3-проверка отложена до пробуждения пользователя. **Что сделано:** Smali-аудит `BleRequest`/`BleResponse`, поиск `RESPONSE_MAP`:
- `DebugResponse` (тот же URI, что запрашивает `enableWirelessAdb`) — НЕ зарегистрирован.
- Мэп есть для Wi-Fi / media / netconfig — но не для debug.
## B2 — USB-adb / сеть очков: на руках есть USB-adb хост ↔ очки; очки же сейчас в wlan0 без IP **Стена:** Сам `ble.request.debug` принимается (`enableWirelessAdb` шлёт `payload.cmd="enableWirelessAdb"`,
ответ должен прийти `WirelessAdbAddress`), но ответа нет — `DebugResponse` отсутствует.
То есть **принятие команды** не зависит от мэпа, но **возврат адреса** — да, и без него
provisioning не получает `host:port`.
Зафиксировано в #B1 — нужно чтобы устройства в общей IP-сети. Wi-Fi-xотспот на телефоне решает. **Следствие:** Автономный wireless-adb через Mercury **на X3 Pro не работает**. Provisioning
работает только пока `adb_wifi_enabled` уже `1` (например, после ручного включения или
shizuku-shell). На ребуте очков зависит от того, выживает ли этот глобальный setting.
## B3 (выяснено, не блокер) — Mercury path `ble.request.debug` не зарегистрирован на прошивке RayNeo X3 Pro **Что НЕ проверял:** Ребут-тест — `adb_wifi_enabled=1` нельзя сейчас проверить, пока
очки не нужны для других задач.
**Что:** `RayNeoMercury.enableWirelessAdb()` отправляет `ble.request.debug/enableWirelessAdb`, на очках приходит `Can not resolve(uri = ble.request.debug)` — RayNeo Mercury system-app на этой прошивке не знает этот URI. **Обход:** Пока работает. Если после ребута очков окажется, что `adb_wifi_enabled=0`,
то provisioning сразу же пробросит через `service call adb 4 i32 1 s16 'Caffeine Portable'`
от shell (через shizuku) и `settings put global adb_wifi_enabled 1` — резервный путь.
Это означает: **провайдер Mercurry не работает на X3 Pro** для управления wireless-adb. ---
**Workaround:** включение wireless-adb через `service call adb 4 i32 1 s16 <ssid>` от shell — отлично работает (с shell UID через Shizuku или напрямую). Используется в `AdbController`/`ShizukuProvisioner`. ## B-3 · Финал: гашение экрана обязательно в конце
По ночным правилам — на завершении всех задач ИЛИ при невозможности продолжать —
выполняется:
```
adb -s A06B4AB933C4103 shell input keyevent 223
```
и проверка `mWakefulness=Dozing/Asleep`. Если не работает — через `ScreenControl` (Канал 1) /
Shizuku `SET_SCREEN`.
@@ -95,6 +95,20 @@
android:resource="@xml/vm_accessibility_service" /> android:resource="@xml/vm_accessibility_service" />
</service> </service>
<!-- 4.1: системный IME-заглушка. Нулевой UI; назначается дефолтным через Shizuku
(settings put secure default_input_method). Обратно НЕ возвращаем. -->
<service
android:name=".VmNoopImeService"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/vm_noop_ime_method" />
</service>
<!-- VmShizukuService НЕ объявляется здесь как <service> — это Shizuku UserService, <!-- VmShizukuService НЕ объявляется здесь как <service> — это Shizuku UserService,
который ServiceStarter создаёт через reflection в отдельном isolated-процессе. который ServiceStarter создаёт через reflection в отдельном isolated-процессе.
Если объявить — Shizuku-сервер не сможет привести класс к IBinder Если объявить — Shizuku-сервер не сможет привести класс к IBinder
@@ -0,0 +1,35 @@
package pw.binom.rayneovm.glasses
import android.inputmethodservice.InputMethodService
import android.util.Log
import android.view.View
import android.view.inputmethod.EditorInfo
/**
* Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay.
*
* Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх
* нашего приложения: InputMethodService.showInputRequestedAndUpdated → false (нет UI).
*
* Активируется через Shizuku/binder: `settings put secure default_input_method
* pw.binom.rayneovm.glasses/.VmNoopImeService`. Возврат к штатному IME не предусмотрен.
*/
class VmNoopImeService : InputMethodService() {
override fun onCreateInputView(): View? {
Log.i(TAG, "onCreateInputView: возвращаю null UI")
return null
}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
Log.i(TAG, "onStartInputView: info=$info restarting=$restarting")
// UI не показываем (onCreateInputView == null). Фокус отдаём гостю, как обычно —
// его кастомные EditText-ы получают inputType как обычно.
}
override fun onFinishInputView(finishedInput: Boolean) {}
companion object {
private const val TAG = "VmNoopIme"
}
}
@@ -32,6 +32,7 @@ import pw.binom.rayneovm.shared.LaunchAppCommand
import pw.binom.rayneovm.shared.Ping import pw.binom.rayneovm.shared.Ping
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.SetText
import pw.binom.rayneovm.shared.Shutdown import pw.binom.rayneovm.shared.Shutdown
import pw.binom.rayneovm.shared.TouchEvent import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.VmMessage import pw.binom.rayneovm.shared.VmMessage
@@ -220,6 +221,11 @@ class VmService : LifecycleService() {
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}") Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
controller.setScreen(msg.sleep) controller.setScreen(msg.sleep)
} }
is SetText -> {
Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}")
val ok = controller.injectText(msg.displayId, msg.text)
Log.i(TAG, "SetText -> $ok")
}
is Shutdown -> { is Shutdown -> {
Log.i(TAG, "Shutdown") Log.i(TAG, "Shutdown")
controller.stopGuest() controller.stopGuest()
@@ -295,6 +295,30 @@ class VmGlassesController {
} }
} }
/**
* Инжектит [text] в активное текстовое поле гостя на [displayId] (через InputConnection.commitText).
*
* Если фокуса в текстовом поле нет — возвращает false (тогда на телефоне остаётся
* keyCodeForChar-фолбэк). Биндер берётся у «стража» (он поднят пока живёт VmService), иначе —
* у активного гостевого дисплея.
*/
fun injectText(displayId: Int, text: String): Boolean {
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "injectText: нет Shizuku-биндера")
return false
}
if (text.isEmpty()) return true
return try {
val ok = ShizukuVdBinder.injectText(binder, displayId, text)
if (!ok) Log.w(TAG, "injectText: binder вернул false")
ok
} catch (t: Throwable) {
Log.w(TAG, "injectText: binder transact упал", t)
false
}
}
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */ /** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
fun sendKey(keyCode: Int, action: Int) { fun sendKey(keyCode: Int, action: Int) {
val src = activeSource ?: return val src = activeSource ?: return
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
IME-метод для [VmNoopImeService]. Никакой UI, никакого ввода — сервис существует лишь
чтобы система не поднимала системный IME, когда гость (Zona) получает фокус в поле ввода.
Включается вторично через настройки системного уровня (см. ShizukuProvisioner).
-->
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:isDefault="false"
android:supportsSwitchingToNextInputMethod="false" />
@@ -111,8 +111,18 @@ class ShizukuProvisioner(
Log.i(TAG, "применяю системные гранты на очках ($serial)") Log.i(TAG, "применяю системные гранты на очках ($serial)")
val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000) val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000)
val appop = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000) val appop = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000)
Log.i(TAG, "гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA=${appop.isSuccess}") // #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]).
if (grant.isSuccess && appop.isSuccess) grantsAppliedInSession = true // Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода.
val ime = adb.shell(
"settings put secure default_input_method " +
"$GLASSES_PACKAGE/.VmNoopImeService",
timeoutMs = 15_000,
)
Log.i(
TAG,
"гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA=${appop.isSuccess}, IME=${ime.isSuccess}",
)
if (grant.isSuccess && appop.isSuccess && ime.isSuccess) grantsAppliedInSession = true
} }
} }
@@ -28,6 +28,7 @@ import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.SetText
import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.VmMessage import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol import pw.binom.rayneovm.shared.VmProtocol
@@ -225,6 +226,17 @@ class VmPhoneController(
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_UP)) send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_UP))
} }
/**
* Инжектит [text] в активное поле ввода гостя на дисплее [displayId] очков.
*
* Работает по [SetText]: очки получают команду, через Shizuku-binder достают InputConnection
* сфокусированного окна на нашем VirtualDisplay и делают `commitText`. Unicode-корректно
* (русский/эмодзи).
*/
suspend fun sendText(text: String, displayId: Int = 0) {
send(SetText(displayId, text))
}
/** /**
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE. * Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager. * Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
@@ -51,6 +51,8 @@ private const val KEYCODE_DPAD_LEFT = 21
private const val KEYCODE_DPAD_RIGHT = 22 private const val KEYCODE_DPAD_RIGHT = 22
private const val KEYCODE_MEDIA_PLAY = 126 private const val KEYCODE_MEDIA_PLAY = 126
private const val KEYCODE_MEDIA_PAUSE = 127 private const val KEYCODE_MEDIA_PAUSE = 127
private const val KEYCODE_DEL = 67
private const val KEYCODE_ENTER = 66
/** Сколько ждать перед кликом-тапом: если за это время коснулись снова — это был скролл. */ /** Сколько ждать перед кликом-тапом: если за это время коснулись снова — это был скролл. */
private const val CLICK_GRACE_MS = 100L private const val CLICK_GRACE_MS = 100L
@@ -74,6 +76,7 @@ fun RemoteScreen(
// Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть» // Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть»
// до начала скролла. Клик ждёт [CLICK_GRACE_MS]; касание за это время его отменяет. // до начала скролла. Клик ждёт [CLICK_GRACE_MS]; касание за это время его отменяет.
val pendingClick = remember { mutableStateOf<Job?>(null) } val pendingClick = remember { mutableStateOf<Job?>(null) }
var showKeyboard by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier.fillMaxSize().background(Color.Black).padding(16.dp), modifier = modifier.fillMaxSize().background(Color.Black).padding(16.dp),
@@ -195,8 +198,21 @@ fun RemoteScreen(
RemoteButton(Icons.Default.Pause, "Пауза", Modifier.weight(1f)) { RemoteButton(Icons.Default.Pause, "Пауза", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_MEDIA_PAUSE) } scope.launch { controller.sendKey(KEYCODE_MEDIA_PAUSE) }
} }
// TODO: клавиатура — отдельная задача. RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f)) {
RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f), enabled = false) {} showKeyboard = true
}
}
if (showKeyboard) {
KeyboardDialog(
onDismiss = { showKeyboard = false },
onSend = { text ->
scope.launch { controller.sendText(text) }
showKeyboard = false
},
onBackspace = { scope.launch { controller.sendKey(KEYCODE_DEL) } },
onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } },
)
} }
} }
} }
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -127,40 +128,59 @@ fun ZonaProfile(
KeyboardDialog( KeyboardDialog(
onDismiss = { showKeyboard = false }, onDismiss = { showKeyboard = false },
onSend = { text -> onSend = { text ->
scope.launch { scope.launch { controller.sendText(text) }
text.forEach { ch ->
val keyCode = keyCodeForChar(ch)
if (keyCode != -1) {
controller.send(VmKeyEvent(keyCode = keyCode, action = 1))
}
}
}
showKeyboard = false showKeyboard = false
}, },
onBackspace = { scope.launch { controller.send(VmKeyEvent(keyCode = 67, action = 1)) } },
onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } },
) )
} }
} }
@Composable @Composable
private fun KeyboardDialog( fun KeyboardDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSend: (String) -> Unit, onSend: (String) -> Unit,
onBackspace: (() -> Unit)? = null,
onEnter: (() -> Unit)? = null,
) { ) {
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Ввод текста") }, title = { Text("Ввод текста") },
text = { text = {
OutlinedTextField( Column {
value = input, OutlinedTextField(
onValueChange = { input = it }, value = input,
modifier = Modifier.fillMaxWidth(), onValueChange = { input = it },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions( singleLine = true,
keyboardType = KeyboardType.Text, keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done, keyboardType = KeyboardType.Text,
), imeAction = ImeAction.Done,
) ),
)
if (onBackspace != null || onEnter != null) {
androidx.compose.foundation.layout.Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (onBackspace != null) {
androidx.compose.material3.OutlinedButton(
onClick = onBackspace,
modifier = Modifier.weight(1f),
) { Text("⌫ Backspace") }
}
if (onEnter != null) {
androidx.compose.material3.OutlinedButton(
onClick = onEnter,
modifier = Modifier.weight(1f),
) { Text("⏎ Enter") }
}
}
}
}
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { onSend(input) }) { Text("Отправить") } TextButton(onClick = { onSend(input) }) { Text("Отправить") }
@@ -171,25 +191,6 @@ private fun KeyboardDialog(
) )
} }
/**
* ASCII char → Android keyCode. Простая таблица, без юникода и без спец.клавиш.
* Возвращает -1 для символов, которые нельзя передать через KEYCODE.
*/
private fun keyCodeForChar(ch: Char): Int {
return when (ch) {
in 'a'..'z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'a')
in 'A'..'Z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'A')
in '0'..'9' -> android.view.KeyEvent.KEYCODE_0 + (ch - '0')
' ' -> android.view.KeyEvent.KEYCODE_SPACE
'\n' -> android.view.KeyEvent.KEYCODE_ENTER
'.' -> android.view.KeyEvent.KEYCODE_PERIOD
',' -> android.view.KeyEvent.KEYCODE_COMMA
'!' -> android.view.KeyEvent.KEYCODE_1 // shift+1 — отдаём как KEYCODE_1 + shift, но тут упрощённо
'?' -> android.view.KeyEvent.KEYCODE_SLASH
else -> -1
}
}
@Composable @Composable
private fun Spacer(modifier: Modifier) { private fun Spacer(modifier: Modifier) {
androidx.compose.foundation.layout.Spacer(modifier = modifier) androidx.compose.foundation.layout.Spacer(modifier = modifier)
@@ -108,6 +108,19 @@ data class SetAudioMode(
@Serializable @Serializable
enum class AudioMode { ON_GLASSES, ON_PHONE } enum class AudioMode { ON_GLASSES, ON_PHONE }
/**
* Передача текста на очки. На очках инжектится в активное поле ввода гостя через
* `ShizukuVdBinder.injectText` (Unicode → InputConnection.commitText), минуя раскладку.
* Используется телефонной клавиатурой; поддерживает русский и английский.
*/
@Serializable
@SerialName("set_text")
data class SetText(
/** DisplayId гостя. 0 = текущий активный. */
val displayId: Int = 0,
val text: String,
) : VmMessage
@Serializable @Serializable
@SerialName("shutdown") @SerialName("shutdown")
data object Shutdown : VmMessage data object Shutdown : VmMessage