#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) на очках +
`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()
@@ -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" />
@@ -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