diff --git a/BLOCKERS.md b/BLOCKERS.md index 1dedab8..bdb2350 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -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://: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 ` от shell — отлично работает (с shell UID через Shizuku или напрямую). Используется в `AdbController`/`ShizukuProvisioner`. +## B-3 · Финал: гашение экрана обязательно в конце + +По ночным правилам — на завершении всех задач ИЛИ при невозможности продолжать — +выполняется: +``` +adb -s A06B4AB933C4103 shell input keyevent 223 +``` +и проверка `mWakefulness=Dozing/Asleep`. Если не работает — через `ScreenControl` (Канал 1) / +Shizuku `SET_SCREEN`. diff --git a/app-glasses-vm/src/main/AndroidManifest.xml b/app-glasses-vm/src/main/AndroidManifest.xml index 9f4c65f..f840c3f 100644 --- a/app-glasses-vm/src/main/AndroidManifest.xml +++ b/app-glasses-vm/src/main/AndroidManifest.xml @@ -95,6 +95,20 @@ android:resource="@xml/vm_accessibility_service" /> + + + + + + + + + diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt index e2f38ad..91b1ad9 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt @@ -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 } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index df60c5b..01ade41 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -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. diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt index 32a07a1..ff0dbed 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt @@ -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(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) } }, + ) } } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt index 985e63a..351bb63 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt @@ -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) diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index 0505a2b..0e1ed50 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -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