#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) на очках +
|
**Когда замечено:** 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()
|
||||||
|
|||||||
+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, ...). */
|
/** Клавиатурное/медиа-событие гостю на его 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" />
|
||||||
+12
-2
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user