#1,#3,#4+#5 keyboards: Binocular Compose, cursor watchdog 3s, SetText + VmNoopIme + sendText
This commit is contained in:
+56
-52
@@ -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) на очках +
|
||||
`AudioPlaybackManager` на телефоне + UI-переключатель «Звук» в `GlassesStatusScreen`.
|
||||
- **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` — работает.
|
||||
**Когда замечено:** 2026-09-27, после серии попыток запуска `free.zona` через debug-broadcast
|
||||
(`DEBUG_LAUNCH`).
|
||||
|
||||
**В чём стена:** 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"`.
|
||||
- Glasses: подключены к тому же `O2 5` через supplicant (CONNECTED), **но без IP на wlan0** (`network is unreachable`).
|
||||
Просто очкам DHCP не отдал адрес (по какой-то причине).
|
||||
- mDNS работает только в broadcast-домене одной L2/L3 сети.
|
||||
**Что сделано:** 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'`.
|
||||
|
||||
Без общего 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 поднимается.
|
||||
- Bluetooth-канал (Mercury) работает (BLE-команды идут). Но для WebSocket audio не применим (пропускная способность BLE всё-таки ограничена для 44.1 кГц стерео, что добавляет сложностей).
|
||||
- ping телефон ↔ очки = `network is unreachable` от очков в сторону телефона (wlan0 не получил IP).
|
||||
**Обход:** Не использовать `DEBUG_LAUNCH`. Для тестов Zona запускать с телефона через штатный
|
||||
контроллер (`controller.startGuest(packageName)` при подключённом канале), либо руками
|
||||
через `adb shell am start -n free.zona/ru.zona.app.android.MainActivity -d 0:2`.
|
||||
|
||||
**Обход (нужна помощь пользователя):**
|
||||
---
|
||||
|
||||
1. **Самый прямой:** пользователь включает Wi-Fi-хотспот на телефоне (через UI, ~30 секунд).
|
||||
- SSID/пароль любые.
|
||||
- Телефон и очки → обе в одну сеть, получают IP от телефона.
|
||||
- Очки уже умеют авто-коннектиться к сохранённой сети (Caffeine Portable или любая).
|
||||
2. **Альтернатива:** пользователь подключает очки к USB-tethering'у с телефона (если dev'ы возможно на устройстве).
|
||||
3. **Самый сложный:** настроить прямой Wi-Fi Direct между телефоном и очками — `cmd wifi p2p ...` потребует более глубокой интеграции.
|
||||
## B-2 · rayneo-mercury беспроводной `enableWirelessAdb` через BLE — не работает на RayNeo X3 Pro
|
||||
|
||||
После появления общей сети проверить:
|
||||
**Когда выяснено:** 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" />
|
||||
</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,
|
||||
который ServiceStarter создаёт через reflection в отдельном isolated-процессе.
|
||||
Если объявить — 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.Pong
|
||||
import pw.binom.rayneovm.shared.SetAudioMode
|
||||
import pw.binom.rayneovm.shared.SetText
|
||||
import pw.binom.rayneovm.shared.Shutdown
|
||||
import pw.binom.rayneovm.shared.TouchEvent
|
||||
import pw.binom.rayneovm.shared.VmMessage
|
||||
@@ -220,6 +221,11 @@ class VmService : LifecycleService() {
|
||||
Log.i(TAG, "ScreenControl: sleep=${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 -> {
|
||||
Log.i(TAG, "Shutdown")
|
||||
controller.stopGuest()
|
||||
|
||||
+24
@@ -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, ...). */
|
||||
fun sendKey(keyCode: Int, action: Int) {
|
||||
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" />
|
||||
+12
-2
@@ -111,8 +111,18 @@ class ShizukuProvisioner(
|
||||
Log.i(TAG, "применяю системные гранты на очках ($serial)")
|
||||
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)
|
||||
Log.i(TAG, "гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA=${appop.isSuccess}")
|
||||
if (grant.isSuccess && appop.isSuccess) grantsAppliedInSession = true
|
||||
// #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]).
|
||||
// Подавляет системный 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.ScreenControl
|
||||
import pw.binom.rayneovm.shared.SetAudioMode
|
||||
import pw.binom.rayneovm.shared.SetText
|
||||
import pw.binom.rayneovm.shared.ShizukuStatus
|
||||
import pw.binom.rayneovm.shared.VmMessage
|
||||
import pw.binom.rayneovm.shared.VmProtocol
|
||||
@@ -225,6 +226,17 @@ class VmPhoneController(
|
||||
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.
|
||||
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
|
||||
|
||||
@@ -51,6 +51,8 @@ private const val KEYCODE_DPAD_LEFT = 21
|
||||
private const val KEYCODE_DPAD_RIGHT = 22
|
||||
private const val KEYCODE_MEDIA_PLAY = 126
|
||||
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
|
||||
@@ -74,6 +76,7 @@ fun RemoteScreen(
|
||||
// Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть»
|
||||
// до начала скролла. Клик ждёт [CLICK_GRACE_MS]; касание за это время его отменяет.
|
||||
val pendingClick = remember { mutableStateOf<Job?>(null) }
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().background(Color.Black).padding(16.dp),
|
||||
@@ -195,8 +198,21 @@ fun RemoteScreen(
|
||||
RemoteButton(Icons.Default.Pause, "Пауза", Modifier.weight(1f)) {
|
||||
scope.launch { controller.sendKey(KEYCODE_MEDIA_PAUSE) }
|
||||
}
|
||||
// TODO: клавиатура — отдельная задача.
|
||||
RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f), enabled = false) {}
|
||||
RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f)) {
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -127,40 +128,59 @@ fun ZonaProfile(
|
||||
KeyboardDialog(
|
||||
onDismiss = { showKeyboard = false },
|
||||
onSend = { text ->
|
||||
scope.launch {
|
||||
text.forEach { ch ->
|
||||
val keyCode = keyCodeForChar(ch)
|
||||
if (keyCode != -1) {
|
||||
controller.send(VmKeyEvent(keyCode = keyCode, action = 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch { controller.sendText(text) }
|
||||
showKeyboard = false
|
||||
},
|
||||
onBackspace = { scope.launch { controller.send(VmKeyEvent(keyCode = 67, action = 1)) } },
|
||||
onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardDialog(
|
||||
fun KeyboardDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
onBackspace: (() -> Unit)? = null,
|
||||
onEnter: (() -> Unit)? = null,
|
||||
) {
|
||||
var input by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Ввод текста") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
)
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
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 = {
|
||||
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
|
||||
private fun Spacer(modifier: Modifier) {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = modifier)
|
||||
|
||||
@@ -108,6 +108,19 @@ data class SetAudioMode(
|
||||
@Serializable
|
||||
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
|
||||
@SerialName("shutdown")
|
||||
data object Shutdown : VmMessage
|
||||
|
||||
Reference in New Issue
Block a user