Compare commits

...

17 Commits

Author SHA1 Message Date
subochev 28d1c02062 очки: HUD (время+заряд) сверху по центру обоих глаз, всегда поверх
Панель перенесена из view-mate (StatusPanel) как часть активити, без виджета:
StatusHud + rememberStatusHudData в :app-glasses-vm/ui. Слой добавляется в
GlassesScreen поверх when(state) внутри Binocular, поэтому виден в любом
состоянии — ожидание/нет связи/запущенный гость — и дублируется в обе
половины (левый и правый глаз).
2026-10-01 17:50:22 +03:00
subochev 65782abfdb клавиатура: подавить системный IME на очках + ввод текста через Accessibility 2026-10-01 17:40:28 +03:00
subochev c02e126bc6 очки: не гасить экран при закрытии посторонней WS-сессии
Экран залипал в «Нет связи», хотя телефон был подключён и команды
доходили. Причина: currentConn — одна ссылка на все сессии, и ЛЮБОЙ
onDisconnected переводил экран в NoConnection. Лишние сессии создаёт
в т.ч. mDNS-фоллбек телефона (probeWebSocket делает полный WS-апгрейд
для проверки кандидата и сразу рвёт соединение).

Теперь очки ведут набор живых сессий и гасят экран только когда
закрылась последняя.
2026-10-01 14:10:34 +03:00
subochev 8be9afb592 аудио: убрать прогрев кодера — Concentus деградировал в ~30x
Прогрев 30 кадров тишины на старте переводил Concentus в патологическое
состояние: enc_us вырастал с ~2.6мс до ~88мс на кадр (113% CPU), кодер не
успевал, терялось ~85% кадров -> «полная жопа» в звуке. Подтверждено A/B:
без прогрева enc_us~2.6мс, dropped единицы, пробник принимает ровно 100/с.
2026-10-01 12:18:40 +03:00
subochev 20d0d05fb4 аудио: убрать потери кадров (Concentus не успевал); экран — POWER
- Opus complexity 8->3, отключён inband FEC: кодер выдавал ~60 кадров/с при 100
  захваченных -> 40% кадров терялось в очереди (DROP_OLDEST) = «хрип».
  Стало enc_us~3мс на 10-мс кадр, dropped перестал расти.
- Прогрев кодера тишиной на старте (JIT Concentus) — убирает стартовый всплеск потерь.
- Диагностика в логе: enc_us / snd_us.
- ScreenControl идемпотентен (POWER — переключатель): сверяемся с реальным isInteractive.
- Зависимость shizuku-vd 0.1.1 -> 0.1.2 (POWER-инъекция).
2026-10-01 12:10:30 +03:00
subochev 4743427756 экран очков: единый тумблер на «Пульте» + очки шлют реальное состояние экрана
- shared: новое сообщение ScreenState(on) от очков телефону
- glasses: слушаем ACTION_SCREEN_ON/OFF (PowerManager.isInteractive), шлём состояние
  при подключении телефона и на каждое изменение; после ScreenControl — актуализация
- phone: VmPhoneController.screenOn (StateFlow<Boolean?>), оптимистичное обновление в setScreen
- phone: на экране «Пульт» одна кнопка-тумблер «Погасить/Разбудить экран очков»,
  подпись отражает реальное состояние, disabled без Канала 1
2026-10-01 11:26:37 +03:00
subochev de6c648c35 аудио: убрать «просадки» — запас буферов + PLC на пропусках
- glasses: очередь capture→Opus 4→12 кадров (джиттер планировщика больше не роняет кадры
  до присвоения seq, отчего телефон не видел потерь); буфер AudioRecord 2→4 кадра
- glasses: диагностика captured/encoded/dropped в логе (видно потери на источнике)
- phone: AudioTrack-буфер 1→4 кадра (редкие underrun'ы давали щелчки); на пропуске seq
  вставляем PLC-кадры (было — сразу склейка); в лог добавлен underruns
2026-10-01 05:58:50 +03:00
subochev d4e1b66924 аудио UDP+Opus (очки→телефон), жесты дужки → гость, «убить всё лишнее» на очках
- shared/audio: вендорнут Concentus (libs/concentus.jar), OpusCodec/AudioDatagram/AudioConfig;
  захват на очках 48кГц стерео → Opus → UDP-датаграммы вместо сырого PCM по WebSocket
- glass.audio: AudioRecord 48кГц с минимальным буфером; UDP-сервер запоминает адрес пира из датаграммы
- glasses: жесты тач-сенсора дужки (cyttsp5_mt) через dispatchTouchEvent → DPAD/BACK гостю (TempleGesture*)
- glasses: forceStopAll — force-stop сторонних + xr.runtime, kill scrcpy-сирот через Shizuku UserService
- phone: AudioPlaybackManager на UDP+Opus+low-latency AudioTrack; кнопка «Убить всё лишнее» на вкладке очков
- deps: mercury transport 0.1.2-SNAPSHOT, shizuku-vd 0.1.1-SNAPSHOT
2026-10-01 05:54:54 +03:00
subochev 8cfd9192b3 mercury: синк часов очков после коннекта BLE (ble.response.set_time)
Как штатное приложение (OnConnected.startSyncTask): сразу после подъёма
BLE-сессии шлём очкам время телефона. Нужно очкам, когда они без сети —
иначе у них своё время и валятся TLS-проверки в гостевых приложениях.

Вызов идемпотентный, ставится и в connectInternal, и в Event.Connected
(внутренние переподключения библиотеки).
2026-09-28 02:27:12 +03:00
subochev e5d2fa24a1 mercury 0.1.1-SNAPSHOT (fixed enableWirelessAdb URI); B-2 resolved
Phone now self-provisions after a glasses reboot: BLE enableWirelessAdb ->
glasses start wireless adb -> address back over BLE -> adb connect ->
Shizuku started + accessibility/media grants applied. Verified on device
16:22 (glasses rebooted, adb_wifi_enabled=0, nothing activated over USB).
2026-09-27 16:24:33 +03:00
subochev 0f6f07782f UI: развести статусы каналов — сводка «Связь» (BLE / TCP/IP); transport 0.1.1-SNAPSHOT; BLOCKERS B-5
Обе строки назывались «Связь» (Канал 1 WebSocket и Канал 2 Mercury BLE), из-за чего
статус BLE читался как статус TCP: при выключенном Wi-Fi на очках телефон показывал
«подключено». Теперь вверху вкладки «Очки» сводка с отдельными строками BLE (Mercury)
и TCP/IP (Wi-Fi), в секциях — «BLE» и «TCP/IP».

mercuryTransport -> 0.1.1-SNAPSHOT (клиентский WS-пинг, см. BLOCKERS B-5).
2026-09-27 15:56:49 +03:00
subochev 3808281d42 fix(audio,#2): переиспользование одного MediaProjection — устранён слом повторного ON_PHONE
- MainActivity: requestTick собирается в repeatOnLifecycle(RESUMED) + флаг AudioBridge.activityResumed
- requestProjection(): не дёргает startActivity(MainActivity), пока Activity RESUMED (гонка с grant)
- ensureAudioCaptureLoop(): запрос проекции один раз, повтор не чаще 20 c (было 2.5 c)
- AudioBridge/GlassesAudioCapture: проекция одна на процесс и не гасится, переиспользуется
- ShizukuProvisioner: PROJECT_MEDIA per-UID (на Android 12 package-level = no-op)
- отладочные логи phone-side audio; BLOCKERS.md B-3 закрыт (E2E проверен)
2026-09-27 12:49:05 +03:00
subochev c485306bc4 BLOCKERS: B-3 закрыт — захват на очках работает, E2E ждёт нажатия UI 2026-09-27 11:33:23 +03:00
subochev e3582e34ec BLOCKERS: переписан по факту (B-1 не подтверждён, B-2 — нет DebugResponse в RESPONSE_MAP, B-3 — очки на другом WiFi) 2026-09-27 11:26:40 +03:00
subochev ad4e458f5c #5 AccessibilityProvisioner: авто-включение VmAccessibilityService на очках при коннекте (по модели ShizukuProvisioner) 2026-09-27 11:22:04 +03:00
subochev 65f3f5d228 #1,#3,#4+#5 keyboards: Binocular Compose, cursor watchdog 3s, SetText + VmNoopIme + sendText 2026-09-27 11:18:52 +03:00
subochev 3ac76daca1 feat(#1,#3): binocular start screen via Binocular{}; cursor auto-hide after 3s idle 2026-09-27 06:42:26 +03:00
31 changed files with 1944 additions and 297 deletions
+153 -53
View File
@@ -1,73 +1,173 @@
# BLOCKERS ночной сессии 2026-09-27
# Blockers
## B1 — Нет общей L3-сети между телефоном и очками для проверки ON_PHONE end-to-end
Сюда пишутся только те задачи/проблемы, в которых застряли. Каждая запись — задача,
что сделано, в чём стена, что проверено, варианты обхода. Записи ведутся по правилам
ночной сессии (см. `TASKS.md`, секция «Правила ночной сессии»).
**Задача:** #2 (проброс звука VirtualDisplay → телефон).
---
**Что сделано:**
## B-1 · Периодический лаунч `Mercury/.NetConfigActivity` — ПОДТВЕРЖДЁН, источник не найден
- `AudioBridge` + `GlassesAudioCapture` + WebSocket-аудио-сервер `/vm-audio` (порт 8081) на очках +
`AudioPlaybackManager` на телефоне + UI-переключатель «Звук» в `GlassesStatusScreen`.
- **MediaProjection + AudioPlaybackCapture подтверждены работающими на очках в автономном debug-режиме
`DEBUG_AUDIO_PIPE`** (preview без WebSocket-канала):
**Когда замечено:** 2026-09-27, во время тестов запуска Zona через `DEBUG_LAUNCH`.
**Что я видел в логах:** `ShizukuVdService: launchActivity pkg=com.ffalconxr.mercury.launcher
cls=com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity displayId=2` повторялось
каждые 5 секунд, постоянно.
**Что это на самом деле:** Непонятно. Это мог быть мой собственный `GlassesDebugReceiver`,
который я перепосылал несколько раз и он где-то зациклился; мог быть артефакт system-job’а;
мог быть какой-то fallback в `VmGlassesController.ensureNetworking()`.
**Повторно замечено:** 2026-09-27 12:28:40, `START ... NetConfigActivity from uid 2000` (shell)
в логах очков во время отладки звука. `uid 2000` = shell ⇒ запускает **Shizuku UserService**
(наш код через Shizuku). Значит источник — где-то в нашем Shizuku-пути (не Mercury BLE).
На аудио-grant влияния не оказал (график grant сломался по другой причине — см. B-3).
**Что НЕ сделано:** не остановил очки в момент пика, не нашёл источник цикла. Пользователь
утром сказал «сейчас вижу наше приложение», значит цикл или закончился, или не повторяется.
**Следующий шаг:** если повторится — переписать GlassesDebugReceiver с rate-limit + проверить
нет ли в `VmService`/`VmGlassesController` периодического `launchActivity(Mercury)` для
Wi-Fi/network fallback.
---
## B-2 · rayneo-mercury: `enableWirelessAdb` через BLE не работал — РЕШЕНО
**Когда выяснено:** 2026-09-27. **Симптом:** после ребута очков provisioning не
проходил — телефон писал «не удалось получить адрес ADB over WiFi на очках»,
Shizuku на очках не поднимался (хотя Wi-Fi и BLE у обоих были живы).
**Настоящая причина — одна и она наша:** библиотека `rayneo-mercury` слала команду
не на тот URI.
1. Очки разбирают входящие кадры по таблице `RESPONSE_MAP[uri]` (`BleResponse` из
`RayNeoLauncher`), и debug-ответ зарегистрирован **только** как
`"ble.response.debug"` → `DebugResponse`.
2. Наша `Commands.Debug.enableWirelessAdb()` слала `"ble.request.debug"` — это
направление очки→телефон.
3. Итог: кадр доходил до очков и **молча выбрасывался** в
`BleServerV2.handleMobileCommand` — не совпал ни с `"brt"`, ни с `RESPONSE_MAP`.
Ни обработки, ни ошибки, ни лога.
Полный JSON, который уходил: `{"uri":"ble.request.debug","payload":{"cmd":"enableWirelessAdb"}}`.
Раньше «адрес не приходит» трактовалось как «очки не отвечают» — на деле очки не
получали команду, поэтому и адрес было некому слать. (Сам адрес очки шлют назад как
`ble.request.debug` + `payload.cmd="WirelessAdbAddress"` — это направление
корректно и у нас уже разобрано.)
**Ground truth — стоковое приложение телефона `com.rayneo.mercury`:**
`cn.rayneo.mercury.connectivity.util.GlassesDebugCommands.enableWirelessAdb()` кладёт
в `BleResponsePipeline` именно `DebugResponse(Payload(cmd="enableWirelessAdb"))`, а у
`DebugResponse` `uri == "ble.response.debug"`. Лишние поля не читаются — обработчик
очков (`DebugKnife.observeBleWirelessDebugCommand`) матчит только `payload.cmd` и сам
вызывает `RayNeoAdbService.enableWirelessAdb(true, <текущий ssid>)`.
**Фикс:** `Commands.Debug.enableWirelessAdb()/disableWirelessAdb()` → `Uris.RESPONSE_DEBUG`;
библиотека опубликована как `0.1.1-SNAPSHOT`, в `rayneo-vm/gradle/libs.versions.toml`
`mercury = "0.1.1-SNAPSHOT"`.
**Проверено на устройстве 2026-09-27 16:22** (очки перезагружены, `adb_wifi_enabled=0`,
по USB ничего не активировал — всё делает телефон):
1. `ble.response.debug` → `DebugResponse dispatched` → `RayNeoAdbService: alwaysAllow=true,
bssid=O2 5` → `open wireless adb success: true` → на телефон вернулся адрес
`192.168.76.248:41395`. Первый ответ бывает `ip:-1` (порт ещё не назначен), через ~0.6 c
приходит второй, валидный.
2. Телефон: `ShizukuProvisioner: starting server... shizuku_starter exit with 0` →
`Shizuku поднят` (`pidof shizuku_server` = 4104).
3. `AccessibilityProvisioner` включил `VmAccessibilityService` через тот же adb.
mDNS-fallback в `AdbController` остаётся как страховка.
**Побочный урок:** `~/.gradle/gradle.properties` содержал левый
`mercuryVersion=0.1.0-SNAPSHOT`, который перебивал проектный, — `publish` молча уходил
в старую версию. Строку убрали.
---
## B-3 · Звук end-to-end — РЕШЕНО
**Когда замечено:** 2026-09-27, E2E-проверка после восстановления общей сети «O2 5».
**Симптом:** звук на телефон работал только в первый раз; после `ON_GLASSES` → `ON_PHONE` тишина
и на очках, и на телефоне.
**Корень (найден по логам 2026-09-27):** не сеть и не телефон, а **grant MediaProjection на очках**.
`ensureAudioCaptureLoop` ре-запрашивал проекцию каждые 2.5 c, а `requestProjection()` каждый раз
дёргал `startActivity(MainActivity, NEW_TASK|SINGLE_TOP)`. Система:
```
TestTone: test tone start: 44100 Hz stereo, 10s
GlassesAudioCapture: capture: bytes=1310720 rms=5629 ← захват системного звука работает!
TestTone: test tone done
GlassesAudioCapture: capture: bytes=1474560 rms=0 ← тишина после теста
START com.android.systemui/.media.MediaProjectionPermissionActivity from uid 10090
→ UsageStats event :23 (ACTIVITY_STOPPED) через ~30 мс
→ MainActivity: MediaProjection не выдан: resultCode=0
```
RMS 5629 совпадает с теоретическим (~5657 для амплитуды 8000) → мы реально ловим 440 Гц.
- Тестовый тон → динамик очков → AudioPlaybackCapture на тех же очках → доказано.
- Подача `Recorder Audio` + `PROJECT_MEDIA=allow` через `AdbController`/`appops` — работает.
то есть grant-активити гасится, если её запускает только что перезапущенная Activity. Ручной
`KEYCODE_HOME` (уводит нашу Activity с переднего плана) «разблокировал» grant — подтверждало
гипотезу гонки.
**В чём стена:** WebSocket-канал (AudioPath между очками и телефоном) требует общую L3-сеть. На данный момент:
**Фикс (вариант A):**
1. `MainActivity` собирает `requestTick` в `repeatOnLifecycle(**RESUMED**)` (был `STARTED`) и флагом
`AudioBridge.activityResumed` сообщает сервису, что она наверху.
2. `requestProjection()` больше **не** вызывает `startActivity(MainActivity)`, пока Activity уже
`RESUMED` (убрана гонка с запуском consent-активити).
3. `ensureAudioCaptureLoop` запрашивает проекцию **один раз**; повтор — не чаще `AUDIO_RETRY_MS=20 c`.
4. Проекция **одна на процесс и не гасится**: `GlassesAudioCapture.stop()` останавливает только
`AudioRecord`, а `AudioBridge.onProjectionGranted` не трогает уже имеющуюся. Следующий `ON_PHONE`
переиспользует ту же проекцию — новый grant не нужен вовсе.
- 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 сети.
**Проверено вживую 2026-09-27 (очки A06B4AB933C4103 → телефон AXGL024B05001337):**
- `[1] ON_PHONE` → `MediaProjection получен` **без HOME** → захват запущен.
- `[2] ON_GLASSES` → `остановлен (проекцию сохраняю)`.
- `[3] ON_PHONE` → `аудио-захват поднят`, **без нового grant** (переиспользование).
- Тестовый тон 440 Гц: очки `rms=5673`, телефон `playing ... rms=5620..5698`, байты совпали
(`bytes=4751360` с обеих сторон). **PCM реально дошёл по `/vm-audio`.**
Без общего IP между устройствами WebSocket `/vm-audio` не пройдёт — `AudioPlaybackManager` на телефоне подключиться не сможет. Сам захват звука на очках доказан, но отправка байтов по WS до телефона — нет.
**Проверено с руки пользователя 2026-09-27:** полный сценарий с Zona — `ON_PHONE` → `ON_GLASSES`
→ `ON_PHONE` — звук корректно переключается, повторный `ON_PHONE` больше не ломается.
**B-3 закрыт полностью.**
**Что проверял:**
---
- `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).
## B-4 · Финал: гашение экрана обязательно в конце
**Обход (нужна помощь пользователя):**
1. **Самый прямой:** пользователь включает Wi-Fi-хотспот на телефоне (через UI, ~30 секунд).
- SSID/пароль любые.
- Телефон и очки → обе в одну сеть, получают IP от телефона.
- Очки уже умеют авто-коннектиться к сохранённой сети (Caffeine Portable или любая).
2. **Альтернатива:** пользователь подключает очки к USB-tethering'у с телефона (если dev'ы возможно на устройстве).
3. **Самый сложный:** настроить прямой Wi-Fi Direct между телефоном и очками — `cmd wifi p2p ...` потребует более глубокой интеграции.
После появления общей сети проверить:
```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=…
По ночным правилам — на завершении всех задач ИЛИ при невозможности продолжать —
выполняется:
```
adb -s A06B4AB933C4103 shell input keyevent 223
```
и проверка `mWakefulness=Dozing/Asleep`. Если не работает — через `ScreenControl` (Канал 1) /
Shizuku `SET_SCREEN`.
**Статус:** #2 прогресс в работе, ядро (MediaProjection + AudioPlaybackCapture) доказано рабочим. Только финальная L3-проверка отложена до пробуждения пользователя.
**Статус:** выполнено (mWakefulness=Dozing подтверждён в конце ночной сессии).
## B2 — USB-adb / сеть очков: на руках есть USB-adb хост ↔ очки; очки же сейчас в wlan0 без IP
---
Зафиксировано в #B1 — нужно чтобы устройства в общей IP-сети. Wi-Fi-xотспот на телефоне решает.
## B-5 · Телефон показывал «подключено» при мёртвом TCP-канале — РЕШЕНО
## B3 (выяснено, не блокер) — Mercury path `ble.request.debug` не зарегистрирован на прошивке RayNeo X3 Pro
**Когда замечено:** 2026-09-27, пользователь: «телефон говорит подключено, очки говорят что нет связи».
**Что:** `RayNeoMercury.enableWirelessAdb()` отправляет `ble.request.debug/enableWirelessAdb`, на очках приходит `Can not resolve(uri = ble.request.debug)` — RayNeo Mercury system-app на этой прошивке не знает этот URI.
**Причина:** у очков Wi-Fi был выключен (`wifi_on=0`, нет default network) — реального Канала 1 не
было. Очки при выключении Wi-Fi локально теряют сокеты мгновенно и честно писали «нет связи».
Телефон же об этом не узнавал: FIN/RST до него не доходит (у очков уже нет интерфейса), а у
**клиента `:transport` не было ни пинга, ни таймаута** — `WifiTransport.connect` вечно висел в
`for (frame in s.incoming)`. TCP-сокет телефона оставался в `ESTAB`, `onDisconnected` не срабатывал,
`VmPhoneController` оставался в `Connected` → UI показывал «подключено».
Это означает: **провайдер Mercurry не работает на X3 Pro** для управления wireless-adb.
**Доказательство (воспроизведено):** выключили Wi-Fi очкам — через 20 c с телефона:
```
ESTAB 192.168.76.121:50438 → 192.168.76.248:8080
```
сокет так и висел, телефон даже не переподключался (те же local-порты после возврата Wi-Fi).
**Workaround:** включение wireless-adb через `service call adb 4 i32 1 s16 <ssid>` от shell — отлично работает (с shell UID через Shizuku или напрямую). Используется в `AdbController`/`ShizukuProvisioner`.
**Фикс (`rayneo-mercury :transport` 0.1.1-SNAPSHOT):** клиентским транспортам добавлен
`install(WebSockets) { pingIntervalMillis = WS_PING_INTERVAL_MS }` (15 c) — `WifiTransport` и
`WifiByteTransport`; константа в `Transport.kt`. Серверные транспорты пингуют и так
(`pingPeriod=15s`, `timeout=30s`), но они ловят мёртвого клиента, а не наоборот.
**Проверено вживую 2026-09-27:** Wi-Fi очкам выключен → на 10-й секунде у телефона `Send-Q=82`
(пошли пинги, недоставленные) → на ~41-й секунде сокет ушёл в `FIN-WAIT-1` (клиент сам закрыл
мёртвую сессию, `onDisconnected` сработал) → после возврата Wi-Fi новый `ESTAB :57996` и в логах
очков `Phone connected: ws-83ad…` (авто-реконнект).
**Побочно:** в UI телефона обе строки назывались одинаково — «Связь» (Канал 1 WebSocket и
Канал 2 Mercury BLE), из-за чего статус BLE читался как статус TCP. Теперь вверху вкладки «Очки»
сводка «Связь» с отдельными строками **BLE (Mercury)** и **TCP/IP (Wi-Fi)**, а в секциях каналов
строки переименованы в «BLE» и «TCP/IP».
@@ -95,6 +95,21 @@
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"
android:permission="android.permission.BIND_INPUT_METHOD">
<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
@@ -7,6 +7,8 @@ import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -29,6 +31,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.input.TempleGestureDetector
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.ui.GlassesScreen
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -38,6 +41,26 @@ class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L
/** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */
private lateinit var controller: VmGlassesController
/**
* Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство
* `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и
* перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через
* [VmGlassesController.sendTempleGesture].
*/
private val templeGestureDetector by lazy {
TempleGestureDetector { gesture -> controller.sendTempleGesture(gesture) }
}
/** id input-устройства дужки (`cyttsp5_mt`); -1 — не найдено. */
private val templeDeviceId: Int by lazy {
InputDevice.getDeviceIds().firstOrNull { id ->
InputDevice.getDevice(id)?.name?.contains(TEMPLE_DEVICE_HINT) == true
} ?: -1
}
/**
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
@@ -71,15 +94,22 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
ShizukuBootstrap.attach(this)
// Диагностика: печатаем input-устройства и id дужки, чтобы в логе было видно,
// куда приходят жесты (см. dispatchTouchEvent).
InputDevice.getDeviceIds().forEach { id ->
val d = InputDevice.getDevice(id)
Log.i(TAG, "input device id=$id name=${d?.name} sources=0x${d?.sources?.toString(16)}")
}
Log.i(TAG, "temple device id=$templeDeviceId (hint='$TEMPLE_DEVICE_HINT')")
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity хотя бы STARTED, иначе лончер кинет.
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
//
// handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе
// Activity в STARTED (а после диалога проекции мы как раз возвращаемся в STARTED).
// Без него один и тот же tick запускал бы запрос повторно, новая проекция гасила
// предыдущую, и AudioRecord умирал с read -2.
// Activity в RESUMED (а после диалога проекции мы как раз возвращаемся в RESUMED).
// Без него один и тот же tick запускал бы запрос повторно.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
AudioBridge.requestTick.collect { tick ->
if (tick > handledTick) {
handledTick = tick
@@ -89,6 +119,7 @@ class MainActivity : ComponentActivity() {
}
}
val controller = VmGlassesController.fromContext(this)
this.controller = controller
setContent {
CompositionLocalProvider(VmGlassesController.Local provides controller) {
@@ -112,9 +143,38 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
AudioBridge.activityResumed = true
ShizukuBootstrap.refresh()
}
/**
* Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки
* (`cyttsp6_mt`) не ассоциированы с дисплеем и иначе теряются: `onTouchEvent` Activity
* при этом может не вызываться вовсе. Всё, что пришло с дужки, отдаём распознавателю и
* потребляем, чтобы не утекло в UI.
*/
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// Диагностика: на ACTION_DOWN печатаем устройство — видно, доходят ли касания дужки
// (deviceId тача дужки = 4, `cyttsp5_mt`) до окна Activity вообще.
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
Log.d(
TAG,
"touch down: deviceId=${event.deviceId} source=0x${event.source.toString(16)} " +
"x=${event.x} y=${event.y}",
)
}
val isTemple = event.deviceId == templeDeviceId && templeDeviceId != -1
if (!isTemple) return super.dispatchTouchEvent(event)
Log.d(TAG, "temple: action=${event.actionMasked} x=${event.x} y=${event.y}")
templeGestureDetector.onTouchEvent(event)
return true
}
override fun onPause() {
AudioBridge.activityResumed = false
super.onPause()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
@@ -136,6 +196,13 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
/**
* Имя input-устройства тач-сенсора дужки RayNeo X3 Pro (проверено через `getevent`:
* все касания дужки приходят на `/dev/input/event2`, устройство `cyttsp5_mt`).
* Второй контроллер `cyttsp6_mt` не генерирует событий.
*/
private const val TEMPLE_DEVICE_HINT = "cyttsp5_mt"
}
}
@@ -2,30 +2,30 @@ package pw.binom.rayneovm.glasses
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/**
* Пассивный Accessibility-сервис GlassesApp.
* Accessibility-сервис GlassesApp.
*
* Зачем: Accessibility-сервис — единственный «легальный» способ для обычного
* приложения (без root/Shizuku/system-app) получить:
* - живучесть процесса: систему сама поднимает и перезапускает сервис,
* - возможность открывать Activity из фона,
* - [onAccessibilityEvent] (в будущем — для отслеживания фокуса ввода и клавиатуры, задача #4).
* Зачем:
* - живучесть процесса: система сама поднимает и перезапускает сервис;
* - «легальный» запуск Activity из фона;
* - ввод текста в поле гостя на VirtualDisplay ([setText], ACTION_SET_TEXT) — IME на
* этих очках сменить нельзя (ROM жёстко держит LatinIME), поэтому системная
* клавиатура гостя отключается, а текст приходит из телефона сюда.
*
* ВАЖНО: сервис НИЧЕГО не делает с чужими окнами — не читает содержимое
* ([android:canRetrieveWindowContent] = false) и не инжектит события. Это
* сознательно: минимум привилегий, минимум риска сломать ввод в гостевых приложениях.
*
* Регистрируется в манифесте через [VM_ACCESSIBILITY_SERVICE] с конфигом
* `res/xml/vm_accessibility_service.xml`. Активируется пользователем вручную:
* Настройки → Специальные возможности → RayNeo VM.
* Регистрируется в манифесте с конфигом `res/xml/vm_accessibility_service.xml`.
* Активируется пользователем вручную: Настройки → Специальные возможности → RayNeo VM.
*/
class VmAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.i(TAG, "Accessibility-сервис подключён")
// Авто-старт: если основное приложение ещё не поднято — поднимаем его.
// Это и есть «легальный авто-запуск» из задачи #5.
@@ -37,17 +37,97 @@ class VmAccessibilityService : AccessibilityService() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Намеренно пусто. Слушаем только для того, чтобы сервис считался живым.
// Тут позже появится логика задачи #4 (фокус поля ввода → скрыть IME гостя).
// Намеренно пусто: слушаем только чтобы сервис считался живым.
}
override fun onInterrupt() {
// Ничего: сервис не выполняет длительных операций.
}
override fun onUnbind(intent: Intent?): Boolean {
if (instance === this) instance = null
return super.onUnbind(intent)
}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
/** Заменяет текст в сфокусированном поле ввода гостя. false — если поля в фокусе нет. */
private fun applyText(text: String): Boolean {
val node = findFocusedInputNode() ?: return false
return try {
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
val ok = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
if (!ok) Log.w(TAG, "ACTION_SET_TEXT вернул false")
ok
} catch (t: Throwable) {
Log.w(TAG, "ACTION_SET_TEXT упал", t)
false
}
}
/**
* Ищет сфокусированный узел ввода: сначала в активном окне, затем во всех
* интерактивных окнах (в т.ч. окно гостя на VirtualDisplay).
*/
private fun findFocusedInputNode(): AccessibilityNodeInfo? {
val active = rootInActiveWindow
val activeFocus = active?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
"findFocused: activeRoot=${active?.packageName} focus=${activeFocus?.className} " +
"editable=${activeFocus?.isEditable}",
)
if (activeFocus?.isEditable == true) return activeFocus
findEditable(active)?.let {
Log.i(TAG, "findFocused: редактируемый узел в активном окне: ${it.className}")
return it
}
val ws = windows
Log.i(TAG, "findFocused: окон=${ws?.size ?: 0}")
ws?.forEach { w ->
val root = w.root ?: return@forEach
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
" окно type=${w.type} pkg=${root.packageName} focus=${focus?.className} editable=${focus?.isEditable}",
)
if (focus?.isEditable == true) return focus
findEditable(root)?.let {
Log.i(TAG, " редактируемый узел: ${it.className} pkg=${root.packageName}")
return it
}
}
return null
}
/** Первый редактируемый (EditText-подобный) потомок в дереве. */
private fun findEditable(root: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
root ?: return null
if (root.isEditable) return root
for (i in 0 until root.childCount) {
findEditable(root.getChild(i))?.let { return it }
}
return null
}
companion object {
private const val TAG = "VmAccessibilitySvc"
/** Активный экземпляр сервиса (в том же процессе, что и [VmService]). */
@Volatile
private var instance: VmAccessibilityService? = null
/** Ввести [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun setText(text: String): Boolean {
if (text.isEmpty()) return true
return instance?.applyText(text) ?: false
}
/** Действие для запроса состояния сервиса/проверки активности (диагностика). */
const val ACTION_PING = "pw.binom.rayneovm.glasses.ACCESSIBILITY_PING"
}
@@ -0,0 +1,70 @@
package pw.binom.rayneovm.glasses
import android.inputmethodservice.InputMethodService
import android.util.Log
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
/**
* Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay.
*
* Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх
* нашего приложения: [onCreateInputView] возвращает null (нет UI).
*
* Заодно сервис служит каналом ввода текста: только IME держит актуальный [InputConnection]
* сфокусированного поля гостя. Сервис живёт в том же процессе, что и [VmService], поэтому
* текст с телефона ([SetText]) доставляется напрямую через [commit] — без раскладки
* и без кросс-процессного доступа к чужому InputConnection.
*
* Активируется через Shizuku: `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 onStartInput(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInput(attribute, restarting)
connection = currentInputConnection
Log.i(TAG, "onStartInput: conn=${connection != null} restarting=$restarting")
}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
super.onStartInputView(info, restarting)
connection = currentInputConnection
Log.i(TAG, "onStartInputView: conn=${connection != null}")
}
override fun onFinishInput() {
connection = null
super.onFinishInput()
}
companion object {
private const val TAG = "VmNoopIme"
/**
* Активный [InputConnection] гостя. Наш IME — дефолтный на очках и живёт в том же
* процессе, что и контроллер, поэтому текст инжектится напрямую через этот объект.
*/
@Volatile
private var connection: InputConnection? = null
/** Доставляет [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun commit(text: String): Boolean {
if (text.isEmpty()) return true
val c = connection ?: return false
return try {
c.commitText(text, 1)
true
} catch (t: Throwable) {
Log.w(TAG, "commitText failed", t)
false
}
}
}
}
@@ -4,14 +4,17 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.media.AudioManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
@@ -32,11 +35,13 @@ 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
import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll
@@ -44,7 +49,11 @@ import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioEncoder
/**
* Foreground service на стороне очков.
@@ -72,19 +81,33 @@ class VmService : LifecycleService() {
private var audioCapture: GlassesAudioCapture? = null
private var audioEnsureJob: kotlinx.coroutines.Job? = null
/** Opus-кодер: используется только из аудио-пампа, поэтому не нужна синхронизация. */
private val audioEncoder = OpusAudioEncoder()
/** seq аудио-датаграмм; оборачивается через переполнение Int — это нормально. */
private var audioSeq = 0
@Volatile
private var audioMode: AudioMode = AudioMode.ON_GLASSES
/**
* Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет.
* При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть,
* чем полнота (иначе задержка растёт безгранично).
* Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
* [AudioRecord] и Opus-кодером.
*
* Ёмкость — запас на джиттер планировщика/GC/кодера; в норме очередь почти пуста, так что
* на задержку это не влияет. DROP_OLDEST остаётся аварийным клапаном, но при ёмкости 12
* (120 мс) срабатывает куда реже, чем при 4 (40 мс), где любой чих кодировщика рождал
* слышимый «провал» (кадр выкидывался до присвоения seq — телефон этого даже не замечал).
*/
private val audioFrames = Channel<ByteArray>(
capacity = 16,
private val audioFrames = Channel<ShortArray>(
capacity = 12,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Диагностика потерь на очках: сколько кадров сняли с AudioRecord и сколько закодировали. */
private val audioCaptured = java.util.concurrent.atomic.AtomicInteger()
private val audioEncoded = java.util.concurrent.atomic.AtomicInteger()
override fun onCreate() {
super.onCreate()
instance = this
@@ -99,6 +122,7 @@ class VmService : LifecycleService() {
startAudioServer()
startAudioPump()
registerMdns()
registerScreenReceiver()
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
// или нам выдали permission) — чтобы телефон не опрашивал вручную.
lifecycleScope.launch {
@@ -150,6 +174,19 @@ class VmService : LifecycleService() {
*/
private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null)
/**
* Все живые WS-сессии. Связь с телефоном считаем живой, пока жива ХОТЬ ОДНА.
*
* Нельзя вешать `NoConnection` на закрытие конкретной сессии: помимо телефона сюда
* прилетают короткоживущие сессии (mDNS-фоллбек телефона делает полный WS-апгрейд
* для проверки кандидата и сразу рвёт соединение). Из-за этого экран очков залипал
* в «Нет связи», хотя телефон был подключён и команды доходили.
*/
private val liveConns = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
/** Ресивер `ACTION_SCREEN_ON/OFF`: сообщаем телефону реальное состояние экрана очков. */
private var screenReceiver: BroadcastReceiver? = null
private fun startServer() {
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
@@ -159,19 +196,26 @@ class VmService : LifecycleService() {
handleIncoming(connId, json)
},
onConnected = { connId ->
Log.i(TAG, "Phone connected: $connId")
liveConns.add(connId)
Log.i(TAG, "Phone connected: $connId (всего сессий: ${liveConns.size})")
currentConn.set(connId)
// Переподключение: снять залипшее «Нет связи с телефоном».
controller.onPhoneConnected()
// И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа).
lifecycleScope.launch { sendShizukuStatus(connId) }
// ...и реальное состояние экрана (для кнопки-тумблера на телефоне).
lifecycleScope.launch { sendScreenState(connId) }
},
onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId")
if (currentConn.compareAndSet(connId, null)) {
liveConns.remove(connId)
Log.i(TAG, "Session closed: $connId (осталось: ${liveConns.size})")
// «Push»-сессию переводим на любую живую, если ушла именно она.
if (currentConn.get() == connId) {
currentConn.set(liveConns.firstOrNull())
}
// Экран гасим ТОЛЬКО когда не осталось ни одной сессии.
if (liveConns.isEmpty()) {
controller.setState(GlassesScreenState.NoConnection)
} else {
Log.i(TAG, "Ignoring disconnect of stale session $connId")
}
},
)
@@ -217,13 +261,34 @@ class VmService : LifecycleService() {
applyAudioMode(msg.mode)
}
is ScreenControl -> {
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
val wantOn = !msg.sleep
val isOn = currentScreenOn()
Log.i(TAG, "ScreenControl: sleep=${msg.sleep} (сейчас on=$isOn)")
if (isOn == wantOn) {
// POWER — переключатель; если цель уже достигнута, инжектить нельзя (моргнём).
lifecycleScope.launch { pushScreenState() }
} else {
controller.setScreen(msg.sleep)
// Экран переключается чуть позже инжекта — сообщаем телефону актуальное состояние.
lifecycleScope.launch {
kotlinx.coroutines.delay(600)
pushScreenState()
}
}
}
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()
}
is ForceStopAll -> {
Log.i(TAG, "ForceStopAll")
controller.forceStopAll()
}
is GetAppList -> {
Log.i(TAG, "GetAppList")
val apps = listInstalledLaunchableApps()
@@ -244,20 +309,26 @@ class VmService : LifecycleService() {
// ---------------- Аудио-мост (задача #2) ----------------
/** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */
/**
* UDP-сервер аудио. Телефон шлёт сюда keepalive/подписку, мы запоминаем адрес
* источника (`ip:port`) как пира и отвечаем Opus-датаграммами.
*
* IP телефона НЕ константа — он берётся из адреса входящей датаграммы. В реальной жизни
* очки подключены к точке доступа самого телефона, адреса меняются от сессии к сессии.
*/
private fun startAudioServer() {
audioServer = Transports.wifiServerBytes(port = AUDIO_PORT, path = AUDIO_PATH)
audioServer = Transports.udpServer(port = AudioConfig.UDP_PORT)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
audioServer!!.accept(
onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ },
onMessage = { _, _ -> /* keepalive/подписка: содержимое не важно, важен источник */ },
onConnected = { connId ->
audioSessions.add(connId)
Log.i(TAG, "Audio client connected: $connId")
Log.i(TAG, "Audio peer connected: $connId")
},
onDisconnected = { connId ->
audioSessions.remove(connId)
Log.i(TAG, "Audio client disconnected: $connId")
Log.i(TAG, "Audio peer disconnected: $connId")
},
)
} catch (t: Throwable) {
@@ -266,18 +337,46 @@ class VmService : LifecycleService() {
}
}
/** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */
/** Кодирует кадры PCM в Opus и рассылает датаграммы всем подключённым аудио-пирам. */
private fun startAudioPump() {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
for (bytes in audioFrames) {
val packet = ByteArray(AudioConfig.MAX_DATAGRAM)
var encNanos = 0L
var sndNanos = 0L
for (pcm in audioFrames) {
val server = audioServer ?: continue
if (audioSessions.isEmpty()) continue
val t0 = System.nanoTime()
val payload = try {
audioEncoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
} catch (t: Throwable) {
Log.w(TAG, "opus encode failed: ${t.message}")
continue
}
val t1 = System.nanoTime()
val len = AudioDatagram.pack(audioSeq++, payload, payload.size, packet)
val datagram = packet.copyOf(len)
for (connId in audioSessions) {
try {
server.send(connId, bytes)
server.send(connId, datagram)
} catch (t: Throwable) {
Log.w(TAG, "audio send failed: ${t.message}")
}
}
val t2 = System.nanoTime()
encNanos += t1 - t0
sndNanos += t2 - t1
val encoded = audioEncoded.incrementAndGet()
if (encoded % 200 == 0) {
val captured = audioCaptured.get()
Log.i(
TAG,
"audio: captured=$captured encoded=$encoded dropped=${captured - encoded} " +
"sessions=${audioSessions.size} enc_us=${encNanos / 200 / 1000} snd_us=${sndNanos / 200 / 1000}",
)
encNanos = 0
sndNanos = 0
}
}
}
}
@@ -301,36 +400,35 @@ class VmService : LifecycleService() {
}
/**
* Пока режим ON_PHONE и захват не поднят — периодически пробуем снова.
* Пока режим ON_PHONE и захват не поднят — доводим его до старта.
*
* Зачем цикл, а не один вызов: projection выдаёт MainActivity асинхронно, а `RECORD_AUDIO`
* может быть ещё не выдан (provisioning телефона выдаёт его через adb). Разовая попытка
* «выстреливает» только когда всё готово; цикл с интервалом [AUDIO_ENSURE_MS] дожимает.
* Частый ре-запрос projection вреден: новая проекция гасит предыдущую — отсюда read -2.
* Проекцию запрашиваем [requestProjection] **один раз** (если её ещё нет). Повторный запрос —
* только если проекция так и не появилась за [AUDIO_RETRY_MS]. Каждый запрос поднимает
* системную grant-активити, а её нельзя запускать, пока наша Activity только-только
* перезапускается — система такие запуски отменяет (`resultCode=0`). Поэтому прежний цикл с
* ре-запросом каждые 2.5 c сам себя и ломал.
*
* Полученную проекцию НЕ гасим ([GlassesAudioCapture.stop] не трогает проекцию): она живёт
* до конца процесса и переиспользуется на все последующие ON_PHONE.
*/
private fun ensureAudioCaptureLoop() {
audioEnsureJob?.cancel()
audioEnsureJob = lifecycleScope.launch {
var fails = 0
if (AudioBridge.projection.value == null) requestProjection()
var waited = 0L
while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
val projection = AudioBridge.projection.value
if (projection == null) {
requestProjection()
} else if (startAudioCapture(projection)) {
if (projection != null && startAudioCapture(projection)) {
Log.i(TAG, "аудио-захват поднят")
break
} else {
// Захват не поднялся (нет RECORD_AUDIO / проекция мертва). Не дёргаем
// новую проекцию сразу — даём провижинингу/системе время; после
// нескольких неудач берём свежую проекцию.
fails++
if (fails >= 3) {
Log.w(TAG, "захват не поднимается ($fails) — берём новую проекцию")
AudioBridge.clearProjection()
fails = 0
}
}
kotlinx.coroutines.delay(AUDIO_ENSURE_MS)
waited += AUDIO_ENSURE_MS
if (projection == null && waited >= AUDIO_RETRY_MS) {
Log.w(TAG, "проекции всё ещё нет — повторный запрос")
requestProjection()
waited = 0L
}
}
}
}
@@ -339,7 +437,10 @@ class VmService : LifecycleService() {
if (audioCapture != null) return true
val capture = GlassesAudioCapture(
projection = projection,
sink = { bytes -> audioFrames.trySend(bytes) },
sink = { bytes ->
audioCaptured.incrementAndGet()
audioFrames.trySend(bytes)
},
onStopped = { lifecycleScope.launch { onCaptureDied() } },
)
return if (capture.start(lifecycleScope)) {
@@ -367,8 +468,10 @@ class VmService : LifecycleService() {
private fun requestProjection() {
AudioBridge.requestProjection()
// MainActivity обычно уже жив (HOME), но если нет — поднимаем: запустить
// ActivityResult-лончер иначе некому.
// MainActivity обычно уже жива и в RESUMED — тогда она сама обработает tick, и трогать
// её нельзя: `startActivity` в этот момент гоняется с запуском consent-активити и
// приводит к отмене grant (resultCode=0). Поднимаем Activity только если она не наверху.
if (!AudioBridge.activityResumed) {
runCatching {
startActivity(
Intent(this, MainActivity::class.java).apply {
@@ -377,6 +480,7 @@ class VmService : LifecycleService() {
)
}.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") }
}
}
private fun setOutputMuted(muted: Boolean) {
runCatching {
@@ -407,6 +511,55 @@ class VmService : LifecycleService() {
sendShizukuStatus(connId)
}
/** Текущее состояние экрана очков (true — экран включён). */
private fun currentScreenOn(): Boolean =
(getSystemService(POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
/** Отправляет состояние экрана конкретной сессии (ответ на подключение / изменение). */
private suspend fun sendScreenState(connId: String) {
val on = currentScreenOn()
val json = VmProtocol.encode(ScreenState(on = on))
try {
server?.send(connId, json)
} catch (t: Throwable) {
Log.w(TAG, "Не удалось отправить ScreenState: ${t.message}")
}
Log.i(TAG, "ScreenState: on=$on")
}
/** Отправляет состояние экрана текущей активной сессии (если есть). */
private suspend fun pushScreenState() {
val connId = currentConn.get() ?: return
sendScreenState(connId)
}
/**
* Слушаем системные `ACTION_SCREEN_ON/OFF`, чтобы держать телефон в курсе реального
* состояния экрана — это питает кнопку-тумблер «экран вкл/выкл» на экране «Пульт».
*/
private fun registerScreenReceiver() {
if (screenReceiver != null) return
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val on = intent?.action == Intent.ACTION_SCREEN_ON
Log.i(TAG, "Экран ${if (on) "включён" else "погашен"}")
lifecycleScope.launch { pushScreenState() }
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}
registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
screenReceiver = receiver
}
private fun unregisterScreenReceiver() {
val r = screenReceiver ?: return
screenReceiver = null
runCatching { unregisterReceiver(r) }
}
private fun createNotificationChannel(): String {
val channelId = "vm_service_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -445,6 +598,7 @@ class VmService : LifecycleService() {
audioServer = null
audioSessions.clear()
unregisterMdns()
unregisterScreenReceiver()
server?.close()
server = null
instance = null
@@ -530,13 +684,12 @@ class VmService : LifecycleService() {
const val WS_PORT = 8080
const val WS_PATH = "/vm"
/** Отдельный порт/путь для PCM-аудио (задача #2). Телефон знает их по константе. */
const val AUDIO_PORT = 8081
const val AUDIO_PATH = "/vm-audio"
/** Интервал ретраев подъёма аудио-захвата (пока режим ON_PHONE). */
/** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
private const val AUDIO_ENSURE_MS = 2_500L
/** Через сколько без проекции повторить её запрос (не чаще — иначе гонка grant). */
private const val AUDIO_RETRY_MS = 20_000L
private const val NOTIFICATION_ID = 100
private const val TAG = "VmService"
@@ -576,7 +729,7 @@ class VmService : LifecycleService() {
val cap = GlassesAudioCapture(
projection = p,
sink = { /* no-op: RMS пишется внутри capture-цикла */ },
).apply { keepProjectionOnStop = true }
)
if (cap.start(svc.lifecycleScope)) {
Log.i(TAG, "DEBUG preview capture started")
kotlinx.coroutines.delay(seconds * 1000L)
@@ -1,6 +1,7 @@
package pw.binom.rayneovm.glasses.audio
import android.media.projection.MediaProjection
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -14,11 +15,18 @@ import kotlinx.coroutines.flow.asStateFlow
* и по запросу поднимает системный интент; полученный projection кладётся сюда, а
* [VmService] его забирает.
*
* Важно (задача #2): проекция **одна на процесс и не гасится**. Повторный grant на этом ROM
* ненадёжен — системная grant-активити отменяется, если её запускает только что перезапущенная
* Activity. Поэтому полученную проекцию мы держим живой и переиспользуем для всех последующих
* `ON_PHONE` (останавливаем только [android.media.AudioRecord], не проекцию).
*
* Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается —
* интент сразу возвращает RESULT_OK.
*/
object AudioBridge {
private const val TAG = "AudioBridge"
private val _requestTick = MutableStateFlow(0L)
/** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */
@@ -26,22 +34,32 @@ object AudioBridge {
private val _projection = MutableStateFlow<MediaProjection?>(null)
/** Текущий выданный projection (null — ещё не получен/сброшен). */
/** Текущий выданный projection (null — ещё не получен). Живёт до конца процесса. */
val projection: StateFlow<MediaProjection?> = _projection.asStateFlow()
/**
* `true`, пока MainActivity в RESUMED. Нужен [VmService], чтобы не дёргать
* `startActivity(MainActivity)` (это ломает grant — гонка с запуском consent-активити),
* когда Activity уже поднята и сама обработает [requestTick].
*/
@Volatile
var activityResumed: Boolean = false
/** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */
fun requestProjection() {
_requestTick.value = _requestTick.value + 1
}
/**
* Принять выданную проекцию. Если проекция уже есть — новую игнорируем и НЕ гасим старую:
* на приложение допускается одна проекция, а её переиспользование — основа стратегии #2.
*/
fun onProjectionGranted(projection: MediaProjection) {
// Медиапроекция на приложение ровно одна: получив новую, гасим старую.
_projection.value?.let { runCatching { it.stop() } }
if (_projection.value != null) {
Log.w(TAG, "проекция уже есть — новую игнорирую (старую не трогаю)")
return
}
_projection.value = projection
}
fun clearProjection() {
_projection.value?.let { runCatching { it.stop() } }
_projection.value = null
Log.i(TAG, "проекция сохранена (живёт до конца процесса)")
}
}
@@ -11,21 +11,25 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.rayneovm.shared.audio.AudioConfig
/**
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
*
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
*
* Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS
* в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
* Пишет 48 кГц stereo 16-bit кадрами ровно по [AudioConfig.FRAME_MS] мс и отдаёт их
* в [sink] на `Dispatchers.IO` как `ShortArray` (interleaved). Кадр фиксированного
* размера нужен Opus-кодеру: он кодирует ровно [AudioConfig.FRAME_SAMPLES] сэмплов/канал.
*
* Раз в ~1 c пишется RMS в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
*
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
*/
class GlassesAudioCapture(
private val projection: MediaProjection,
private val sink: suspend (ByteArray) -> Unit,
private val sink: suspend (ShortArray) -> Unit,
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
private val onStopped: (() -> Unit)? = null,
) {
@@ -33,15 +37,9 @@ class GlassesAudioCapture(
private var job: Job? = null
@Volatile private var running = false
/**
* `true` — при [stop] НЕ вызывать [MediaProjection.stop].
* Используется в debug-preview (`DEBUG_TEST_AUDIO_PIPE`), когда проекцию выдавать отдельно.
*/
var keepProjectionOnStop: Boolean = false
fun start(scope: CoroutineScope): Boolean {
if (running) return true
val sampleRate = SAMPLE_RATE
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -50,7 +48,9 @@ class GlassesAudioCapture(
Log.w(TAG, "getMinBufferSize=$minBuf")
return false
}
val bufferSize = minBuf * 4
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше четырёх кадров
// (40 мс): при двух кадрах любой сбой планировщика/GC приводил к overrun и «просадке».
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 4)
val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
@@ -84,21 +84,26 @@ class GlassesAudioCapture(
running = true
rec.startRecording()
job = scope.launch(Dispatchers.IO) {
val buf = ByteArray(FRAME_BYTES)
val frame = ShortArray(AudioConfig.FRAME_SHORTS)
var filled = 0
var frameCount = 0
var totalBytes = 0L
var totalSamples = 0L
while (isActive && running) {
val read = rec.read(buf, 0, buf.size)
val read = rec.read(frame, filled, frame.size - filled)
if (read > 0) {
filled += read
if (filled == frame.size) {
try {
sink(buf.copyOf(read))
sink(frame.copyOf())
} catch (t: Throwable) {
Log.w(TAG, "sink упал: ${t.message}")
}
totalBytes += read
totalSamples += filled
frameCount++
if (frameCount % 40 == 0) {
Log.i(TAG, "capture: bytes=$totalBytes rms=${rms16(buf, read).toInt()}")
if (frameCount % 100 == 0) {
Log.i(TAG, "capture: samples=$totalSamples rms=${rms16(frame).toInt()}")
}
filled = 0
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read ошибка: $read")
@@ -111,10 +116,15 @@ class GlassesAudioCapture(
onStopped?.invoke()
}
}
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize")
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, кадр=${AudioConfig.FRAME_MS}мс, buffer=$bufferSize")
return true
}
/**
* Останавливает только [AudioRecord]. Проекцию НЕ гасит: она одна на процесс и
* переиспользуется (см. [AudioBridge]) — иначе следующий `ON_PHONE` требовал бы нового
* grant, который на этом ROM ненадёжен.
*/
fun stop() {
running = false
job?.cancel()
@@ -124,29 +134,19 @@ class GlassesAudioCapture(
runCatching { it.release() }
}
record = null
Log.i(TAG, "AudioPlaybackCapture остановлен")
if (!keepProjectionOnStop) {
runCatching { projection.stop() }
}
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
}
private fun rms16(buf: ByteArray, len: Int): Double {
if (len < 2) return 0.0
private fun rms16(buf: ShortArray): Double {
if (buf.isEmpty()) return 0.0
var sumSq = 0.0
var i = 0
while (i + 1 < len) {
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
for (s in buf) {
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (len / 2))
return Math.sqrt(sumSq / buf.size)
}
companion object {
private const val TAG = "GlassesAudioCapture"
const val SAMPLE_RATE = 44100
/** ~23 мс @ 44.1 кГц 16-bit stereo — маленькая задержка без перегрузки кадрами. */
private const val FRAME_BYTES = 4096
}
}
@@ -0,0 +1,117 @@
package pw.binom.rayneovm.glasses.input
/**
* Жест, распознанный на тач-сенсоре дужки RayNeo X3 Pro.
*
* SDK RayNeo ([com.rayneo.arsdk.android.touch.TempleAction]) даёт похожий набор, но мы
* распознаём сами: логика тривиальна, а обфусцированный `TouchDispatcher` тянуть не хочется.
*
* Дужка на X3 Pro — отдельное input-устройство `cyttsp5_mt` (`/dev/input/event2`), у которого
* НЕТ ассоциированного дисплея (`AssociatedDisplay: displayId=''`), поэтому события приходят
* в окно Activity, но Compose/View-иерархия их не интерпретирует как касание. Перехватываем
* их в [android.app.Activity.dispatchTouchEvent] и превращаем в [TempleGesture].
*/
sealed interface TempleGesture {
/** Короткое одиночное касание. */
data object Click : TempleGesture
/** Два касания подряд — по умолчанию «назад» в госте. */
data object DoubleClick : TempleGesture
/**
* Завершённый свайп по дужке с итоговым смещением.
*
* @param dx смещение по X в пикселях сенсора (дужка — длина тач-зоны, ось «вдоль»).
* @param dy смещение по Y в пикселях сенсора (ось «поперёк»).
* @param velocity приблизительная скорость, px/с.
*/
data class Swipe(val dx: Float, val dy: Float, val velocity: Float) : TempleGesture
}
/**
* Распознаватель жестов тач-сенсора дужки.
*
* Питается обычными [android.view.MotionEvent], отфильтрованными по устройству дужки
* (см. `MainActivity.TEMPLE_DEVICE_HINT`). Классификация на ACTION_UP:
* - путь < [TAP_SLOP_PX] и время < [LONG_PRESS_MS] → [TempleGesture.Click];
* - путь ≥ [SLIDE_SLOP_PX] → [TempleGesture.Swipe].
*
* Двойной клик собирается по двум [TempleGesture.Click] подряд с интервалом ≤ [DOUBLE_TAP_MS]:
* второй превращается в [TempleGesture.DoubleClick], первый «съедается» ожиданием.
*
* Состояния между касаниями почти нет (только таймер двойного клика). Вызывать строго
* с UI-потока (`dispatchTouchEvent`).
*/
class TempleGestureDetector(
private val onGesture: (TempleGesture) -> Unit,
) {
private var downX = 0f
private var downY = 0f
private var downTime = 0L
/**
* Одиночный клик откладываем на [DOUBLE_TAP_MS]: если второй тап придёт в окно, первый
* превратится в [TempleGesture.DoubleClick], а не в отдельный [TempleGesture.Click].
* Без этой задержки двойной тап давал бы сначала лишний «выбор», и только потом «назад».
*/
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
private var clickPending = false
private val emitPendingClick = Runnable {
clickPending = false
onGesture(TempleGesture.Click)
}
fun onTouchEvent(event: android.view.MotionEvent) {
when (event.actionMasked) {
android.view.MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
downTime = event.eventTime
}
android.view.MotionEvent.ACTION_UP -> {
val dt = event.eventTime - downTime
val dx = event.x - downX
val dy = event.y - downY
val distance = kotlin.math.hypot(dx, dy)
if (distance < TAP_SLOP_PX && dt < LONG_PRESS_MS) {
if (clickPending) {
handler.removeCallbacks(emitPendingClick)
clickPending = false
onGesture(TempleGesture.DoubleClick)
} else {
clickPending = true
handler.postDelayed(emitPendingClick, DOUBLE_TAP_MS)
}
} else if (distance >= SLIDE_SLOP_PX) {
cancelPendingClick()
val velocity = distance / dt.coerceAtLeast(1L).toFloat() * 1000f
onGesture(TempleGesture.Swipe(dx = dx, dy = dy, velocity = velocity))
}
}
android.view.MotionEvent.ACTION_CANCEL -> {
downTime = 0L
}
}
}
private fun cancelPendingClick() {
handler.removeCallbacks(emitPendingClick)
}
companion object {
/** Движение короче этого считаем касанием, а не свайпом (в пикселях сенсора). */
const val TAP_SLOP_PX = 40f
/** Смещение, с которого касание становится свайпом. */
const val SLIDE_SLOP_PX = 80f
/** Нажатие дольше этого — не клик (пока не обрабатываем, резерв). */
const val LONG_PRESS_MS = 600L
/**
* Максимальный интервал между двумя кликами для склейки в двойной и одновременно
* задержка одиночного клика (см. [emitPendingClick]).
*/
const val DOUBLE_TAP_MS = 300L
}
}
@@ -26,7 +26,10 @@ import kotlinx.coroutines.sync.withLock
import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.mercury.shizuku.ShizukuVdClient
import pw.binom.rayneovm.glasses.VmAccessibilityService
import pw.binom.rayneovm.glasses.VmNoopImeService
import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.input.TempleGesture
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.VmDisplay
@@ -74,6 +77,13 @@ class VmGlassesController {
/** Насос скролла: один на приложение. */
private var scrollPump: Job? = null
/**
* Watchdog автоскрытия курсора: после [CURSOR_HIDE_MS] бездействия курсор гасится. Перезапускается
* на каждом действии тачпада. Курсор появится вновь при следующем move/click/scroll — место
* появления не принципиально (новое — от последнего moveCursor).
*/
private var cursorHideJob: Job? = null
@Volatile private var appContext: Context? = null
/** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
@@ -159,6 +169,7 @@ class VmGlassesController {
x = x.coerceIn(0f, VmDisplay.WIDTH - 1f),
y = y.coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
scheduleCursorHide()
}
/**
@@ -172,6 +183,7 @@ class VmGlassesController {
val pos = _cursor.value ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
scheduleCursorHide()
testScope.launch {
val ok = ShizukuVdBinder.tap(binder, displayId, pos.x, pos.y)
if (!ok) Log.w(TAG, "clickCursor: tap failed (displayId=$displayId)")
@@ -192,10 +204,30 @@ class VmGlassesController {
*/
fun scrollCursor(dx: Float, dy: Float) {
if (dx == 0f && dy == 0f) return
scheduleCursorHide()
scrollChannel.trySend(floatArrayOf(dx, dy))
ensureScrollPump()
}
/**
* Перезапускает watchdog автоскрытия курсора. Если гость работает и через [CURSOR_HIDE_MS] не
* будет новых действий с тачпада — прячем курсор.
*/
private fun scheduleCursorHide() {
if (activeSource == null) return
cursorHideJob?.cancel()
cursorHideJob = testScope.launch {
delay(CURSOR_HIDE_MS)
_cursor.value = null
}
}
/** Останавливает watchdog и очищает курсор (вызов из [stopGuest]). */
private fun cancelCursorHide() {
cursorHideJob?.cancel()
cursorHideJob = null
}
/** Один насос на приложение: копит дельты и инжектит пачкой. */
private fun ensureScrollPump() {
if (scrollPump != null) return
@@ -266,6 +298,31 @@ class VmGlassesController {
}
}
/**
* Инжектит [text] в активное текстовое поле гостя на [displayId] (через InputConnection.commitText).
*
* Если фокуса в текстовом поле нет — возвращает false (тогда на телефоне остаётся
* keyCodeForChar-фолбэк). Биндер берётся у «стража» (он поднят пока живёт VmService), иначе —
* у активного гостевого дисплея.
*/
fun injectText(displayId: Int, text: String): Boolean {
if (text.isEmpty()) return true
// Основной путь: AccessibilityService + ACTION_SET_TEXT — единственный способ
// достучаться до поля гостя на VirtualDisplay без IME (сменить IME на этих очках
// нельзя, а системная клавиатура гостя отключена).
if (VmAccessibilityService.setText(text)) {
Log.i(TAG, "injectText: доставлено через Accessibility (len=${text.length})")
return true
}
// Резерв: если наш IME всё-таки является активным — шлём через его InputConnection.
if (VmNoopImeService.commit(text)) {
Log.i(TAG, "injectText: доставлено через IME (len=${text.length})")
return true
}
Log.w(TAG, "injectText: нет активного поля ввода (ни Accessibility, ни IME)")
return false
}
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
fun sendKey(keyCode: Int, action: Int) {
val src = activeSource ?: return
@@ -277,6 +334,49 @@ class VmGlassesController {
}
}
/**
* Жест тач-сенсора дужки → событие гостю на VirtualDisplay.
*
* Маппинг — DPAD-клавиши (крестовина джойстика, которую понимает гость, например ZONA):
* - [TempleGesture.Swipe] с доминирующей осью X → LEFT/RIGHT, осью Y → UP/DOWN;
* - [TempleGesture.DoubleClick] → KEYCODE_BACK («назад»);
* - [TempleGesture.Click] → KEYCODE_DPAD_CENTER (подтверждение).
*
* Знаки осей (какой свайп = «вверх») подбираются эмпирически на устройстве.
* Каждому событию инжектим DOWN+UP парой, как одиночное нажатие клавиши.
*/
fun sendTempleGesture(gesture: TempleGesture) {
val src = activeSource ?: run {
Log.d(TAG, "sendTempleGesture($gesture): нет активного гостя")
return
}
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
val keyCode: Int = when (gesture) {
is TempleGesture.Swipe -> {
if (kotlin.math.abs(gesture.dx) >= kotlin.math.abs(gesture.dy)) {
if (gesture.dx >= 0f) android.view.KeyEvent.KEYCODE_DPAD_RIGHT
else android.view.KeyEvent.KEYCODE_DPAD_LEFT
} else {
if (gesture.dy >= 0f) android.view.KeyEvent.KEYCODE_DPAD_DOWN
else android.view.KeyEvent.KEYCODE_DPAD_UP
}
}
is TempleGesture.DoubleClick -> android.view.KeyEvent.KEYCODE_BACK
is TempleGesture.Click -> android.view.KeyEvent.KEYCODE_DPAD_CENTER
}
Log.i(TAG, "sendTempleGesture: $gesture -> keyCode=$keyCode")
testScope.launch {
val down = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_DOWN,
)
val up = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_UP,
)
if (!down || !up) Log.w(TAG, "sendTempleGesture: injectKeyEvent failed (keyCode=$keyCode)")
}
}
/**
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast.
@@ -353,12 +453,50 @@ class VmGlassesController {
acquireMutex.withLock {
killActiveGuest()
releaseFrameSource()
cancelCursorHide()
_cursor.value = null
setState(GlassesScreenState.NoAppSelected)
}
}
}
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + `kill -9` процессов-сирот вроде scrcpy.
*
* Выполняется нашим Shizuku UserService от shell UID. Защищены: сам GlassesApp, Shizuku
* (через него же и работаем) и активный гость на VirtualDisplay — по просьбе пользователя
* его не трогаем. Дисплей гостя при этом не освобождаем: убьём только процессы вокруг.
*/
fun forceStopAll() {
testScope.launch {
// Страж живёт постоянно (watchdog), его binder — надёжный долгоживущий вариант.
// Если стража нет, берём binder активного гостя.
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "forceStopAll: нет Shizuku binder (стража нет, гостя нет)")
return@launch
}
val protected = buildList {
add(GLASSES_PACKAGE)
add(SHIZUKU_PACKAGE)
activeGuestPackage?.let { add(it) }
}
val killed = runCatching {
ShizukuVdBinder.forceStopAll(
binder = binder,
protectedPackages = protected,
extraPackages = listOf(XR_RUNTIME_PACKAGE),
processNameSubstrings = listOf(SCRCPY_PROCESS),
)
}.getOrElse {
Log.w(TAG, "forceStopAll failed", it)
-1
}
Log.i(TAG, "forceStopAll: killed=$killed (protected=$protected)")
}
}
/**
* Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex].
*
@@ -551,6 +689,9 @@ class VmGlassesController {
/** Скролл-драг: как часто сливаем накопленные дельты, порог и сброс «хвоста». */
private const val SCROLL_TICK_MS = 90L
/** Через сколько мс простоя тачпада прятать курсор. */
private const val CURSOR_HIDE_MS = 3_000L
/** Минимальная длина драга: короче гость трактует как клик по элементу (проверено на Settings ~50-60 px). */
private const val SCROLL_MIN_PX = 96f
@@ -562,6 +703,21 @@ class VmGlassesController {
private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
/** Наш пакет — не убиваем себя при «убить всё» (см. [forceStopAll]). */
private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
/** Shizuku — не убиваем: через его UserService и выполняем force-stop. */
private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
/**
* RayNeo XR-runtime. Формально системный (`system_ext/priv-app`), но именно он держит
* открытой аппаратную камеру и жрёт ~100% CPU — поэтому его принудительно снимаем.
*/
private const val XR_RUNTIME_PACKAGE = "com.rayneo.xr.runtime"
/** Подстрока командной строки процессов-зеркал (scrcpy), которые тоже добиваем. */
private const val SCRCPY_PROCESS = "scrcpy"
private const val GUARD_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240
@@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.mercury.binocular.Binocular
import pw.binom.mercury.binocular.BinocularRendererView
import pw.binom.mercury.binocular.NoOpFrameSource
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
@@ -45,6 +46,7 @@ fun GlassesScreen(
val state by controller.state.collectAsState()
val controllerFrameSource by controller.frameSource.collectAsState()
val cursor by controller.cursor.collectAsState()
val hud = rememberStatusHudData()
val noOp = remember { NoOpFrameSource() }
val frameSource = controllerFrameSource ?: noOp
@@ -55,36 +57,57 @@ fun GlassesScreen(
modifier = Modifier.fillMaxSize(),
)
// Compose overlay поверх GL
// Compose overlay поверх GL. Простые статусы рисуем бинокулярно (по тексту на каждый глаз)
// через [Binocular]; GL-слой и курсор мыши внутри Binocular НЕ оборачиваем — они сами
// справляются (рендер и точки в обеих половинах).
when (val s = state) {
is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...")
is GlassesScreenState.ShizukuMissing -> StatusOverlay(
is GlassesScreenState.Starting -> Binocular { StatusOverlay("Подготавливаем очки...") }
is GlassesScreenState.ShizukuMissing -> Binocular {
StatusOverlay(
title = "Shizuku не активен",
subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" +
"Тапните здесь, чтобы дать разрешение.",
onClick = onRequestShizukuPermission,
)
is GlassesScreenState.NoConnection -> StatusOverlay(
}
is GlassesScreenState.NoConnection -> Binocular {
StatusOverlay(
title = "Нет связи с телефоном",
subtitle = "Убедитесь, что телефон и очки в одной WiFi-сети."
)
is GlassesScreenState.NoAppSelected -> StatusOverlay(
}
is GlassesScreenState.NoAppSelected -> Binocular {
StatusOverlay(
title = "Выберите приложение на телефоне",
subtitle = "Откройте приложение «RayNeo VM» и выберите приложение для запуска.\n\n[DEBUG] Тап-тест: запустить Zona сейчас",
onClick = {
controller.startTestLaunch("free.zona")
}
)
}
is GlassesScreenState.Running -> {
// Ничего поверх GL, кроме курсора мыши: FrameSource уже установлен контроллером.
cursor?.let { CursorOverlay(it.x, it.y) }
}
is GlassesScreenState.Error -> StatusOverlay(
is GlassesScreenState.Error -> Binocular {
StatusOverlay(
title = "Ошибка",
subtitle = s.message,
)
}
}
// HUD (время + заряд) — всегда поверх, в обеих половинах (на обоих глазах),
// сверху по центру. Виден в любом состоянии: и в ожидании связи, и поверх гостя.
Binocular {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
StatusHud(hud, modifier = Modifier.padding(top = 6.dp))
}
}
}
}
/**
@@ -0,0 +1,125 @@
package pw.binom.rayneovm.glasses.ui
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** Данные HUD: время + заряд + цвет индикатора заряда. */
data class StatusHudData(
val time: String,
val percent: Int,
val chargeColorArgb: Int,
)
private const val CHARGE_COLOR_CHARGING = 0xFF4CAF50.toInt() // зелёный — идёт заряд
private const val CHARGE_COLOR_DISCHARGING = 0xFFFFFFFF.toInt() // белый — разряд
private const val CHARGE_COLOR_PLUGGED_NOT_CHARGING = 0xFFE53935.toInt() // красный — подключён, но не заряжается
/** Цвет индикатора заряда: не подключён=белый, CHARGING/FULL=зелёный, иначе красный. */
private fun chargeColorArgb(plugged: Boolean, status: Int): Int = when {
!plugged -> CHARGE_COLOR_DISCHARGING
status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL -> CHARGE_COLOR_CHARGING
else -> CHARGE_COLOR_PLUGGED_NOT_CHARGING
}
/**
* HUD очков: время + заряд, компактной «пилюлей» на полупрозрачном тёмном фоне.
*
* Панель вызывается внутри [pw.binom.mercury.binocular.Binocular] (см. `GlassesScreen`),
* поэтому автоматически дублируется в обеих половинах — левом и правом глазу.
*/
@Composable
fun StatusHud(data: StatusHudData, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(Color(0x99000000))
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(data.time, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.width(6.dp))
// Процент — цветом состояния заряда, без отдельного квадратика.
Text("${data.percent}%", color = Color(data.chargeColorArgb), fontSize = 14.sp)
}
}
/**
* Провайдер данных HUD: время обновляется раз в секунду, заряд — из sticky
* `ACTION_BATTERY_CHANGED`. Держится один на весь экран; обе половины читают одни данные.
*/
@Composable
fun rememberStatusHudData(): StatusHudData {
val context = LocalContext.current
var data by remember {
mutableStateOf(
StatusHudData(
time = "--:--",
percent = 0,
chargeColorArgb = CHARGE_COLOR_DISCHARGING,
)
)
}
DisposableEffect(Unit) {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
var percent = 0
var plugged = false
var status = BatteryManager.BATTERY_STATUS_UNKNOWN
fun readBattery() {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
?: return
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
status = intent.getIntExtra(
BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN
)
plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
percent = if (scale > 0 && level >= 0) {
(level * 100f / scale).roundToInt().coerceIn(0, 100)
} else {
level.coerceIn(0, 100)
}
}
scope.launch {
while (isActive) {
readBattery()
data = StatusHudData(timeFormat.format(Date()), percent, chargeColorArgb(plugged, status))
delay(1_000)
}
}
onDispose { scope.cancel() }
}
return data
}
@@ -2,20 +2,22 @@
<!--
Конфиг Accessibility-сервиса RayNeo VM.
Сервис — пассивный слушатель: он НЕ управляет чужими окнами и НЕ инжектит
события. Нужен только как «легальный» живучий процесс, который система
поднимает/перезапускает сама, и через который можно открывать Activity из фона.
Сервис — «легальный» живучий процесс, который система поднимает/перезапускает сама,
через который можно открывать Activity из фона, и — главное — единственный
корректный путь ввода текста в поле гостя на VirtualDisplay без IME
(ACTION_SET_TEXT). Чужой ввод при этом НЕ инжектится.
Никаких eventTypes/feedbackType не запрашиваем сверх необходимого:
eventTypes="typeWindowStateChanged" — минимально нужный, чтобы сервис считался
активным и `onAccessibilityEvent` вызывался. canRetrieveWindowContent=false —
содержимое окон нам не нужно, меньше привилегий.
eventTypes: typeWindowStateChanged|typeViewFocused — второе нужно, чтобы ловить
фокус поля ввода (в будущем).
canRetrieveWindowContent=true — иначе ACCESSIBILITY недоступен rootInActiveWindow и
ACTION_SET_TEXT не выполнить. flagRetrieveInteractiveWindows — чтобы видеть окна
гостя на VirtualDisplay, а не только окно по умолчанию.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canRetrieveWindowContent="false"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:description="@string/vm_accessibility_description"
android:notificationTimeout="100"
android:summary="@string/vm_accessibility_summary" />
@@ -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" />
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.bootstrap.AccessibilityProvisioner
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
@@ -24,7 +25,7 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
* и [MercuryController] (второй канал — RayNeo Mercury BLE).
*/
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
ShizukuProvisionerProvider, AdbControllerProvider {
ShizukuProvisionerProvider, AdbControllerProvider, AccessibilityProvisionerProvider {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -34,6 +35,9 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
private val provisioner: ShizukuProvisioner by lazy {
ShizukuProvisioner(this, mercuryController, controller, adbController)
}
private val accessibilityProvisioner: AccessibilityProvisioner by lazy {
AccessibilityProvisioner(controller, adbController)
}
override fun onCreate() {
super.onCreate()
@@ -77,7 +81,28 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
// Разрыв BLE — новая сессия: при следующем подключении проверяем Shizuku заново.
appScope.launch {
mercuryController.state.collect { st ->
if (st !is MercuryState.Connected) provisioner.onBleDisconnected()
if (st !is MercuryState.Connected) {
provisioner.onBleDisconnected()
accessibilityProvisioner.onBleDisconnected()
}
}
}
// Accessibility — отдельный provisioner, гоняется по тому же расписанию, что и Shizuku.
appScope.launch {
controller.state.collect { st ->
if (st is ConnectionState.Connected && mercuryController.state.value is MercuryState.Connected) {
Log.i(TAG, "WiFi подключение к очкам — проверяю Accessibility")
accessibilityProvisioner.ensure()
}
}
}
appScope.launch {
while (true) {
delay(PROVISION_RETRY_MS)
if (mercuryController.state.value is MercuryState.Connected) {
accessibilityProvisioner.ensure()
}
}
}
}
@@ -86,6 +111,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
super.onTerminate()
ConnectionKeepAliveService.stop(this)
provisioner.close()
accessibilityProvisioner.close()
controller.stop()
mercuryController.close()
}
@@ -94,6 +120,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
override fun provideMercuryController(): MercuryController = mercuryController
override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner
override fun provideAdbController(): AdbController = adbController
override fun provideAccessibilityProvisioner(): AccessibilityProvisioner = accessibilityProvisioner
companion object {
private const val TAG = "PhoneVmApp"
@@ -106,6 +133,11 @@ interface ShizukuProvisionerProvider {
fun provideShizukuProvisioner(): ShizukuProvisioner
}
/** Провайдер автовключения Accessibility-сервиса на очках для UI. */
interface AccessibilityProvisionerProvider {
fun provideAccessibilityProvisioner(): AccessibilityProvisioner
}
/** Провайдер adb-контроллера (единая точка входа для adb-команд) для UI. */
interface AdbControllerProvider {
fun provideAdbController(): AdbController
@@ -9,17 +9,25 @@ import android.os.PowerManager
import android.util.Log
import pw.binom.mercury.transport.ByteTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Менеджер воспроизведения аудио с очков на телефон (Audio mode 2).
* Воспроизведение аудио с очков на телефоне (Audio mode 2).
*
* Держит AudioTrack + WiFi/Wake lock + WebSocket-клиент, качающий PCM с очков.
* Канал — **UDP + Opus**: телефон шлёт очкам keepalive-датаграммы (так очки запоминают
* наш адрес как пира) и принимает Opus-пакеты, декодирует и пишет в low-latency [AudioTrack].
*
* UDP выбран вместо WebSocket: устаревшие пакеты дропаются, а не копятся (нет роста задержки).
*
* Вызывается из [pw.binom.rayneovm.phone.protocol.VmPhoneController] при получении
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
@@ -30,18 +38,31 @@ class AudioPlaybackManager(private val context: Context) {
private var track: AudioTrack? = null
private var transport: ByteTransport? = null
private var streamJob: Job? = null
private var keepAliveJob: Job? = null
private var wifiLock: WifiManager.WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null
fun start(glassesHost: String) {
if (track != null) return // уже запущено
@Volatile
private var running = false
val sampleRate = SAMPLE_RATE
private val decoder = OpusAudioDecoder()
private val pcmOut = ShortArray(AudioConfig.FRAME_SHORTS)
fun start(glassesHost: String) {
if (track != null) {
Log.w(TAG, "start: уже running — пропускаю (host=$glassesHost)")
return
}
Log.i(TAG, "start: host=$glassesHost")
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)
val bufferSize = minBuffer * 4
// Четыре кадра (40 мс) запаса: при буфере меньше этого редкие подвисания потока
// приёма/GC опустошали очередь и давали слышимый «провал» (underrun).
val bufferSize = maxOf(minBuffer, AudioConfig.FRAME_BYTES * 4)
track = AudioTrack.Builder()
.setAudioAttributes(
@@ -59,9 +80,10 @@ class AudioPlaybackManager(private val context: Context) {
)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build()
track!!.play()
Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize")
Log.i(TAG, "AudioTrack started: $sampleRate Hz stereo, buffer=$bufferSize")
// WiFi + Wake lock
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
@@ -71,36 +93,85 @@ class AudioPlaybackManager(private val context: Context) {
acquire(60 * 60 * 1000L)
}
// Подключаемся к очкам по тому же ws://host:port/vm-audio
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
running = true
transport = Transports.udp(glassesHost, AudioConfig.UDP_PORT)
val subscribe = ByteArray(0)
streamJob = scope.launch {
var totalBytes = 0L
var frames = 0
var lastSeq = -1
var lost = 0
try {
transport!!.connect(
onMessage = { pcm ->
onMessage = { datagram ->
if (!AudioDatagram.hasPayload(datagram)) return@connect
val seq = AudioDatagram.seq(datagram)
val t = track ?: return@connect
t.write(pcm, 0, pcm.size)
totalBytes += pcm.size
if (lastSeq >= 0) {
// Оборачивание Int через переполнение даёт «огромный» gap — отсекаем.
val gap = seq - lastSeq - 1
if (gap in 1 until MAX_GAP) {
lost += gap
// PLC: синтезируем заменители потерянных кадров, чтобы на месте
// пропуска не было резкого «щелчка» на стыке.
repeat(minOf(gap, MAX_CONCEAL)) {
val n = try {
decoder.decodeLost(pcmOut)
} catch (e: Throwable) {
-1
}
if (n > 0) t.write(pcmOut, 0, n * AudioConfig.CHANNELS)
}
}
}
lastSeq = seq
val decoded = try {
decoder.decode(
datagram,
AudioDatagram.HEADER_SIZE,
datagram.size - AudioDatagram.HEADER_SIZE,
pcmOut,
)
} catch (e: Throwable) {
Log.w(TAG, "opus decode failed: ${e.message}")
-1
}
if (decoded > 0) {
t.write(pcmOut, 0, decoded * AudioConfig.CHANNELS)
}
frames++
if (frames % 40 == 0) {
Log.i(TAG, "playing: bytes=$totalBytes rms=${rms16(pcm).toInt()}")
if (frames % 200 == 0) {
Log.i(TAG, "playing: frames=$frames lost=$lost underruns=${t.underrunCount} lastSeq=$seq")
}
},
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
onDisconnected = { Log.w(TAG, "audio disconnected") },
onConnected = {
Log.i(TAG, "audio udp connected to $glassesHost:${AudioConfig.UDP_PORT}")
// Подписка: заставляем очки запомнить наш адрес как аудио-пира.
runCatching { transport!!.send(subscribe) }
},
onDisconnected = { Log.w(TAG, "audio udp disconnected from $glassesHost") },
)
} catch (t: kotlinx.coroutines.CancellationException) {
// Штатная остановка (stop()/смена режима) — не ошибка.
} catch (t: CancellationException) {
Log.i(TAG, "stream job cancelled (stop)")
} catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t)
}
Log.i(TAG, "stream job завершился frames=$frames lost=$lost")
}
// Очки считают пира живым, пока от него идут датаграммы (peerTimeoutMs). Держим связь.
keepAliveJob = scope.launch {
while (isActive && running) {
delay(AudioConfig.KEEPALIVE_MS)
runCatching { transport?.send(subscribe) }
}
}
}
fun stop() {
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
running = false
keepAliveJob?.cancel(); keepAliveJob = null
streamJob?.cancel(); streamJob = null
transport?.close(); transport = null
track?.stop(); track?.release(); track = null
@@ -109,23 +180,13 @@ class AudioPlaybackManager(private val context: Context) {
Log.i(TAG, "AudioPlaybackManager stopped")
}
private fun rms16(buf: ByteArray): Double {
if (buf.size < 2) return 0.0
var sumSq = 0.0
var i = 0
while (i + 1 < buf.size) {
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (buf.size / 2))
}
private companion object {
const val TAG = "AudioPlaybackMgr"
companion object {
private const val TAG = "AudioPlaybackMgr"
const val SAMPLE_RATE = 44100
/** Пропуск seq больше этого — оборачивание Int или сбой, а не реальные потери. */
const val MAX_GAP = 100
/** Совпадает с [pw.binom.rayneovm.glasses.VmService.AUDIO_PORT]. */
const val GLASSES_PORT = 8081
/** Максимум кадров подряд «дотягиваем» PLC, чтобы не залипнуть на длинной потере. */
const val MAX_CONCEAL = 5
}
}
@@ -0,0 +1,138 @@
package pw.binom.rayneovm.phone.bootstrap
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.protocol.VmPhoneController
/**
* Гарантирует, что VmAccessibilityService на очках включён.
*
* Используется ровно тот же путь, что и [ShizukuProvisioner] (через
* [AdbController] → беспроводной adb → `settings put secure`). Если у
* `AdbController` нет соединения, мы НЕ поднимаем adb силой — это работа
* бутстрапа Shizuku; accessibility всё равно будет включен при следующем
* успешном коннекте.
*
* Вызывается на каждый BLE-коннект (см. [VmPhoneController] и
* `ShizukuProvisioner.ensure()`). Так ловим случаи `am force-stop` /
* обновления APK, которые сбрасывают accessibility-флаг.
*/
class AccessibilityProvisioner(
private val controller: VmPhoneController,
private val adb: AdbController,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
private val _state = MutableStateFlow<AccessibilityState>(AccessibilityState.Idle)
val state: StateFlow<AccessibilityState> = _state.asStateFlow()
/**
* Проверить, что VmAccessibilityService есть в `enabled_accessibility_services`.
* Если нет — добавить. Один раз за BLE-сессию.
*/
fun ensure() {
Log.i(TAG, "ensure(): состояние=${_state.value}")
if (mutex.isLocked) {
Log.i(TAG, "ensure(): уже идёт проверка")
return
}
scope.launch {
mutex.withLock { ensureLocked() }
}
}
/**
* BLE-сессия разорвана — при следующем коннекте снова проверим.
* (См. проверенное: `am force-stop` снимает accessibility-флаг,
* поэтому перепроверяем каждый раз.)
*/
fun onBleDisconnected() {
_state.value = AccessibilityState.Idle
}
private suspend fun ensureLocked() {
if (!adb.available) {
// Без adb мы ничего не сделаем — но состояние не сбрасываем, чтобы UI не моргал.
Log.i(TAG, "ensureLocked(): adb недоступен, пропуск")
return
}
// Шарим соединение с ShizukuProvisioner (если оно поднято).
val serial = adb.ensureConnected() ?: run {
Log.i(TAG, "ensureLocked(): нет adb-соединения")
return
}
Log.i(TAG, "ensureLocked(): adb=$serial, проверяю accessibility")
val current = readEnabledServices().getOrDefault("")
Log.i(TAG, "ensureLocked(): enabled_accessibility_services='$current'")
if (current.contains(SERVICE_NAME)) {
_state.value = AccessibilityState.Enabled
return
}
_state.value = AccessibilityState.Enabling
val append = if (current.isBlank()) SERVICE_FQDN else "$current:$SERVICE_FQDN"
val put = adb.shell(
"settings put secure enabled_accessibility_services '$append'",
timeoutMs = 15_000,
)
val enable = adb.shell(
"settings put secure accessibility_enabled 1",
timeoutMs = 15_000,
)
if (put.isSuccess && enable.isSuccess) {
Log.i(TAG, "ensureLocked(): accessibility включён")
_state.value = AccessibilityState.Enabled
} else {
val msg = "settings put failed: put=${put.isSuccess}, enable=${enable.isSuccess}"
Log.w(TAG, "ensureLocked(): $msg")
_state.value = AccessibilityState.Failed(msg)
}
}
private suspend fun readEnabledServices(): Result<String> = runCatching {
val out = adb.shell("settings get secure enabled_accessibility_services", timeoutMs = 15_000)
.getOrElse { error("shell failed: ${it.message}") }
// Если значение равно «null» — это дефолтное состояние, без включённых сервисов.
out.trim().takeIf { it.isNotEmpty() && it != "null" } ?: ""
}
fun close() {
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
}
private companion object {
const val TAG = "AccessibilityProvisioner"
const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
const val SERVICE_NAME = "VmAccessibilityService"
// settings put ожидает формат ComponentName через package/class (НЕ .FQCN, а /FQCN).
const val SERVICE_FQDN = "$GLASSES_PACKAGE/$GLASSES_PACKAGE.$SERVICE_NAME"
}
}
/** Состояние автовключения accessibility на очках (для UI). */
sealed interface AccessibilityState {
data object Idle : AccessibilityState
data object Enabling : AccessibilityState
data object Enabled : AccessibilityState
data class Failed(val message: String) : AccessibilityState
val label: String
get() = when (this) {
Idle -> "не проверялся"
Enabling -> "включаем…"
Enabled -> "включён"
is Failed -> "ошибка: $message"
}
}
@@ -103,6 +103,11 @@ class ShizukuProvisioner(
/**
* Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure():
* `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku.
*
* `PROJECT_MEDIA` ставится и для пакета, и для UID — иначе на свежих прошивках
* `MediaProjectionManager.createScreenCaptureIntent()` возвращает `RESULT_CANCELED`,
* даже если `cmd appops get <pkg> PROJECT_MEDIA` показывает `allow`. Проверено на
* RayNeo X3 Pro / Android 12 (см. BLOCKERS.md, B-3 follow-up).
*/
private fun maybeApplyGrants() {
if (grantsAppliedInSession) return
@@ -110,9 +115,39 @@ class ShizukuProvisioner(
val serial = adb.ensureConnected() ?: return@launch
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
val appopPkg = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000)
// PROJECT_MEDIA на Android 12+ привязан к UID, не к пакету: даже если per-package
// стоит allow, диалог проекции сразу RESULT_CANCELED пока per-UID = ignore.
val uidOut = adb.shell("cmd package list packages -U $GLASSES_PACKAGE", timeoutMs = 15_000).getOrNull()
val uid = uidOut?.lineSequence()
?.mapNotNull { Regex("""uid:(\d+)""").find(it)?.groupValues?.get(1)?.toIntOrNull() }
?.firstOrNull()
val appopUid = if (uid != null) {
adb.shell("cmd appops set --uid $uid PROJECT_MEDIA allow", timeoutMs = 15_000)
} else {
Log.w(TAG, "не удалось узнать UID $GLASSES_PACKAGE — PROJECT_MEDIA per-UID не выставлен")
Result.failure(IllegalStateException("uid not found"))
}
// #4.1: глушим системную клавиатуру гостя. Наш VmNoopImeService дефолтом стать
// не может — вендорский ROM жёстко держит LatinIME (allowlist; даже системный
// Sogou откатывается). Поэтому просто отключаем LatinIME: на очках не поднимается
// ни один IME, а текст с телефона доставляется через AccessibilityService
// (ACTION_SET_TEXT, см. VmAccessibilityService). На «обычных» ROM сработает и
// первый способ — назначение нашего IME.
val ime = adb.shell(
"ime enable $GLASSES_PACKAGE/.VmNoopImeService; " +
"ime set $GLASSES_PACKAGE/.VmNoopImeService; " +
"pm disable-user --user 0 com.android.inputmethod.latin",
timeoutMs = 15_000,
)
Log.i(
TAG,
"гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA[pkg]=${appopPkg.isSuccess}" +
"[uid=$uid]=${appopUid.isSuccess}, IME=${ime.isSuccess}",
)
if (grant.isSuccess && appopPkg.isSuccess && appopUid.isSuccess && ime.isSuccess) {
grantsAppliedInSession = true
}
}
}
@@ -187,6 +187,7 @@ class MercuryController(private val context: Context) {
}
saveAddress(glasses)
_state.value = MercuryState.Connected(glasses, batteryPercent = null)
syncClock()
startStatusRefresh()
} catch (t: Throwable) {
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
@@ -269,6 +270,23 @@ class MercuryController(private val context: Context) {
_state.value = MercuryState.Disconnected()
}
/**
* Ставим часы очков по телефону сразу после коннекта — как штатное приложение
* (его `OnConnected.startSyncTask`). Очкам синк нужен, когда они без сети: иначе
* у них своё время и, например, валятся TLS-проверки сертификатов в гостевых
* приложениях. Команда идемпотентна — повторная отправка безвредна.
*/
private fun syncClock() {
scope.launch {
try {
val ok = mercury.syncTime()
android.util.Log.i(TAG, "syncTime -> $ok")
} catch (t: Throwable) {
android.util.Log.w(TAG, "syncTime failed: ${t.message}")
}
}
}
/**
* Настойчиво тянем статус после подключения: очки присылают `general.status`
* при изменениях, а ответ на первый запрос легко теряется (BLE-нотификация
@@ -487,6 +505,9 @@ class MercuryController(private val context: Context) {
mercury.events.collect { event ->
when (event) {
is RayNeoMercury.Event.Connected -> {
// Событие приходит и при внутренних переподключениях библиотеки —
// держим часы очков синхронными на любом подъёме BLE.
syncClock()
val cur = _state.value
if (cur is MercuryState.Connecting) {
_state.value = MercuryState.Connected(cur.device, batteryPercent = null)
@@ -21,13 +21,16 @@ import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
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
@@ -54,6 +57,10 @@ class VmPhoneController(
private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null)
val glassesShizuku: StateFlow<ShizukuStatus?> = _glassesShizuku.asStateFlow()
/** Реальное состояние экрана очков (шлют очки); null — пока неизвестно. */
private val _screenOn = MutableStateFlow<Boolean?>(null)
val screenOn: StateFlow<Boolean?> = _screenOn.asStateFlow()
private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) }
@@ -120,6 +127,7 @@ class VmPhoneController(
onDisconnected = {
currentTransport = null
_glassesShizuku.value = null
_screenOn.value = null
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
audioManager.stop()
_state.value = ConnectionState.Disconnected
@@ -157,6 +165,9 @@ class VmPhoneController(
is ShizukuStatus -> {
_glassesShizuku.value = msg
}
is ScreenState -> {
_screenOn.value = msg.on
}
is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается.
@@ -189,6 +200,14 @@ class VmPhoneController(
send(RequestShizukuStatus)
}
/**
* «Убить всё лишнее» на очках: force-stop сторонних приложений + RayNeo XR-runtime
* (греющая камера) + процессы-сироты вроде scrcpy. Наши приложения и системное не трогает.
*/
suspend fun forceStopAll() {
send(ForceStopAll)
}
/** Отправляет сообщение очкам. */
suspend fun send(msg: VmMessage) {
val t = currentTransport ?: return
@@ -216,6 +235,8 @@ class VmPhoneController(
suspend fun setScreen(sleep: Boolean): Boolean {
if (_state.value !is ConnectionState.Connected) return false
send(ScreenControl(sleep))
// Оптимистично: точное состояние очки пришлют сами (ScreenState) — возможно с задержкой.
_screenOn.value = !sleep
return true
}
@@ -225,11 +246,23 @@ 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.
*/
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
android.util.Log.i("VmPhoneController", "setAudioMode: prev=${_audioMode.value} → $mode state=${_state.value}")
_audioMode.value = mode
send(SetAudioMode(mode))
when (mode) {
@@ -237,6 +270,7 @@ class VmPhoneController(
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (_state.value as? ConnectionState.Connected)?.host
if (host != null) {
android.util.Log.i("VmPhoneController", "setAudioMode: ON_PHONE → audioManager.start($host)")
audioManager.start(host)
} else {
// Не подключены — команда уйдёт при следующем подключении не сразу,
@@ -245,6 +279,7 @@ class VmPhoneController(
}
}
}
android.util.Log.i("VmPhoneController", "setAudioMode done: mode=$mode audioManager=${audioManager}")
}
companion object {
@@ -113,6 +113,17 @@ fun GlassesStatusScreen(
fontWeight = FontWeight.Bold,
)
SectionCard(title = "Связь") {
StatusRow(
"BLE (Mercury)",
if (mercuryConnected) "есть" else "нет",
)
StatusRow(
"TCP/IP (Wi-Fi)",
if (connection is ConnectionState.Connected) "есть" else "нет",
)
}
Channel1WebSocketSection(connection = connection, glasses = glasses)
ShizukuSection(
@@ -162,6 +173,11 @@ fun GlassesStatusScreen(
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
)
ForceStopSection(
enabled = connection is ConnectionState.Connected,
onForceStop = { controller.forceStopAll() },
)
Spacer(modifier = Modifier.height(8.dp))
LaunchGlassesAppButton(
@@ -183,7 +199,7 @@ private fun Channel1WebSocketSection(
glasses: pw.binom.rayneovm.phone.protocol.GlassesStatusInfo?,
) {
SectionCard(title = "Канал 1 — наше приложение (WebSocket)") {
StatusRow("Связь", connection.label)
StatusRow("TCP/IP", connection.label)
when (connection) {
is ConnectionState.Connected -> glasses?.let { g ->
StatusRow("Аккумулятор", g.batteryPercent?.let { "$it%" } ?: "—")
@@ -270,13 +286,13 @@ private fun Channel2MercurySection(
OutlinedButton(onClick = onScan) { Text("Проверить ещё раз") }
}
is MercuryState.Disconnected -> {
StatusRow("Связь", "нет связи")
StatusRow("BLE", "нет связи")
mercury.message?.let { Hint(it) }
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onScan) { Text("Сканировать") }
}
is MercuryState.Scanning -> {
StatusRow("Связь", "поиск… (найдено ${mercury.found.size})")
StatusRow("BLE", "поиск… (найдено ${mercury.found.size})")
Spacer(modifier = Modifier.height(8.dp))
if (mercury.found.isNotEmpty()) {
Text("Найденные устройства:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
@@ -288,11 +304,11 @@ private fun Channel2MercurySection(
}
}
is MercuryState.Connecting -> {
StatusRow("Связь", "сопрягаем ${mercury.device.name ?: mercury.device.address}…")
StatusRow("BLE", "сопрягаем ${mercury.device.name ?: mercury.device.address}…")
Hint("Если очки запрашивают PIN — подтвердите на них.")
}
is MercuryState.Connected -> {
StatusRow("Связь", "подключено (${mercury.device.name ?: mercury.device.address})")
StatusRow("BLE", "подключено (${mercury.device.name ?: mercury.device.address})")
StatusRow("Адрес", mercury.device.address)
StatusRow("Аккумулятор", mercury.batteryPercent?.let { "$it%" } ?: "—")
Spacer(modifier = Modifier.height(8.dp))
@@ -420,6 +436,54 @@ private fun AudioOutputSection(
}
}
/**
* «Убить всё лишнее» на очках: снимает сторонние приложения, RayNeo XR-runtime
* (он держит камеру и греет) и процессы-сироты вроде scrcpy. Наши приложения, Shizuku и
* активный гость на виртуальном дисплее остаются жить.
*/
@Composable
private fun ForceStopSection(
enabled: Boolean,
onForceStop: suspend () -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
SectionCard(title = "Обслуживание") {
Hint(
"Остановит все лишние приложения на очках (включая RayNeo XR-runtime, который " +
"греет) и зеркала экрана. Наше приложение и текущий гость не тронутся.",
)
Button(
enabled = !busy && enabled,
onClick = {
busy = true
message = null
scope.launch {
try {
onForceStop()
message = "Команда отправлена на очки"
} catch (t: Throwable) {
message = "Ошибка: ${t.message}"
} finally {
busy = false
}
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8E2A2A),
disabledContainerColor = Color(0xFF2A2A2A),
),
) {
Text(if (busy) "Отправляем…" else "Убить всё лишнее на очках")
}
if (!enabled) Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
message?.let { Hint(it) }
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,
@@ -20,10 +20,12 @@ import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -42,6 +44,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.VmDisplay
@@ -51,6 +54,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
@@ -69,11 +74,15 @@ fun RemoteScreen(
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
// Реальное состояние экрана очков (шлют сами очки) — питает кнопку-тумблер ниже.
val screenOn by controller.screenOn.collectAsState()
val connected = controller.state.collectAsState().value is ConnectionState.Connected
// Позиция курсора в координатах VirtualDisplay гостя.
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) }
// Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть»
// до начала скролла. Клик ждёт [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 +204,37 @@ 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
}
}
// Одна кнопка-тумблер: гасит/будит экран очков. Символ отражает текущее состояние
// (его шлют очки), поэтому нажатие всегда чередует вкл/выкл корректно.
Row(modifier = Modifier.fillMaxWidth()) {
RemoteButton(
icon = Icons.Default.PowerSettingsNew,
label = when (screenOn) {
true -> "Погасить экран очков"
false -> "Разбудить экран очков"
null -> "Экран очков: вкл/выкл"
},
modifier = Modifier.weight(1f),
enabled = connected,
) {
scope.launch { controller.setScreen(sleep = screenOn != false) }
}
}
if (showKeyboard) {
KeyboardDialog(
onDismiss = { showKeyboard = false },
onSend = { text ->
scope.launch { controller.sendText(text) }
showKeyboard = false
},
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,30 +128,27 @@ 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
},
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 = {
Column {
OutlinedTextField(
value = input,
onValueChange = { input = it },
@@ -161,6 +159,27 @@ private fun KeyboardDialog(
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 +190,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)
+3 -3
View File
@@ -10,10 +10,10 @@ composeBom = "2026.09.00"
shizukuApi = "13.1.5"
shizukuProvider = "13.1.5"
navCompose = "2.10.2"
mercury = "0.1.0-SNAPSHOT"
mercuryTransport = "0.1.0-SNAPSHOT"
mercury = "0.1.1-SNAPSHOT"
mercuryTransport = "0.1.2-SNAPSHOT"
mercuryBinocular = "0.1.0-SNAPSHOT"
mercuryShizuku = "0.1.0-SNAPSHOT"
mercuryShizuku = "0.1.2-SNAPSHOT"
[libraries]
# kotlinx
+4
View File
@@ -35,6 +35,10 @@ dependencies {
api(libs.mercury.transport)
// Concentus — чистый JVM-порт libopus (BSD-3), вендорнут в libs/ чтобы сборка не
// зависела от внешнего JitPack. Используется обёрткой pw.binom.rayneovm.shared.audio.OpusCodec.
implementation(files("libs/concentus.jar"))
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotlin.test) // need for kotlin.test.Test
Binary file not shown.
@@ -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
@@ -128,6 +141,18 @@ data class ScreenControl(
@SerialName("get_app_list")
data object GetAppList : VmMessage
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + добить процессы-сироты вроде scrcpy.
*
* Выполняется на очках их Shizuku UserService (shell UID). Защищены от убийства: сам
* GlassesApp, Shizuku, все системные/привилегированные пакеты и текущий гость на
* VirtualDisplay.
*/
@Serializable
@SerialName("force_stop_all")
data object ForceStopAll : VmMessage
/** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
@Serializable
@SerialName("request_shizuku_status")
@@ -171,6 +196,18 @@ data class AppLaunchedAck(
val success: Boolean,
) : VmMessage
/**
* Состояние экрана очков: [on] = true — экран включён, false — погашен.
*
* Очки шлют его при подключении телефона и при каждом изменении (`ACTION_SCREEN_ON`/`OFF`),
* чтобы кнопка-тумблер на телефоне отражала реальное состояние, а не догадку.
*/
@Serializable
@SerialName("screen_state")
data class ScreenState(
val on: Boolean,
) : VmMessage
/**
* Состояние Shizuku на очках.
*
@@ -0,0 +1,47 @@
package pw.binom.rayneovm.shared.audio
/**
* Единые параметры аудио-тракта очки → телефон.
*
* Opus не поддерживает 44.1 кГц, поэтому весь тракт (захват, кодирование, вывод) работает
* на 48 кГц. Кадр 10 мс держит задержку маленькой, а сжатие Opus (~64 кбит/с против
* ~1.5 Мбит/с сырого PCM) уменьшает трафик примерно в 20 раз.
*/
object AudioConfig {
const val SAMPLE_RATE = 48_000
const val CHANNELS = 2
/** Длительность одного Opus-кадра. Компромисс: меньше — меньше задержка, больше накладные. */
const val FRAME_MS = 10
/** Сэмплов на канал в одном кадре (480 @ 10 мс / 48 кГц). */
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
/** Всего сэмплов (interleaved) в кадре. */
const val FRAME_SHORTS = FRAME_SAMPLES * CHANNELS
/** Размер кадра PCM (interleaved 16-bit). */
const val FRAME_BYTES = FRAME_SHORTS * 2
const val BITRATE = 64_000
/**
* Сложность Opus-кодера. Concentus — JVM-порт, кодирует на очках в реальном времени;
* при complexity 8 один 10-мс stereo-кадр не укладывался в 10 мс, кодер не успевал
* (~60 кадров/с при 100 захваченных → 40% кадров терялось в очереди = «хрип»). На 3
* запас по CPU большой, а на 64 кбит/с разница в качестве незначительна.
*/
const val COMPLEXITY = 3
/** Ожидаемый процент потерь — для настройки inband FEC/PLC Opus. */
const val PACKET_LOSS_PERCENT = 10
/** UDP-порт аудио на очках (control-канал остаётся на TCP 8080). */
const val UDP_PORT = 8082
/** Верхняя граница размера датаграммы (Opus 10 мс stereo ≪ этого). */
const val MAX_DATAGRAM = 1500
/** Интервал keepalive-датаграмм от телефона: держит пира «живым» на UDP-сервере очков. */
const val KEEPALIVE_MS = 1_000L
}
@@ -0,0 +1,37 @@
package pw.binom.rayneovm.shared.audio
/**
* Формат аудио-датаграммы: `[4 байта seq big-endian][Opus payload]`.
*
* `seq` позволяет телефону детектить потери/дубли и понимать, что поток «жив», а не завис.
* Заголовок намеренно минимальный — каждые лишние байты это лишняя задержка.
*/
object AudioDatagram {
const val HEADER_SIZE = 4
const val MAX_PAYLOAD = AudioConfig.MAX_DATAGRAM - HEADER_SIZE
/**
* Кладёт [payload] в [out] с заголовком seq. Возвращает полную длину датаграммы.
* Буфер [out] должен быть не меньше `HEADER_SIZE + payloadLength`.
*/
fun pack(seq: Int, payload: ByteArray, payloadLength: Int, out: ByteArray): Int {
out[0] = (seq ushr 24).toByte()
out[1] = (seq ushr 16).toByte()
out[2] = (seq ushr 8).toByte()
out[3] = seq.toByte()
System.arraycopy(payload, 0, out, HEADER_SIZE, payloadLength)
return HEADER_SIZE + payloadLength
}
/** Читает seq из датаграммы; `-1`, если данных меньше заголовка (например keepalive). */
fun seq(data: ByteArray, length: Int = data.size): Int {
if (length < HEADER_SIZE) return -1
return ((data[0].toInt() and 0xFF) shl 24) or
((data[1].toInt() and 0xFF) shl 16) or
((data[2].toInt() and 0xFF) shl 8) or
(data[3].toInt() and 0xFF)
}
/** Есть ли в датаграмме вообще полезная нагрузка (не keepalive). */
fun hasPayload(data: ByteArray, length: Int = data.size): Boolean = length > HEADER_SIZE
}
@@ -0,0 +1,75 @@
package pw.binom.rayneovm.shared.audio
import org.concentus.OpusApplication
import org.concentus.OpusDecoder
import org.concentus.OpusEncoder
/**
* Обёртка над Concentus (чистый JVM-порт libopus, без NDK).
*
* Один экземпляр держит один Opus-поток, поэтому его нельзя использовать из нескольких
* потоков одновременно. Класс не потокобезопасен — вызывающий синхронизирует сам.
*/
class OpusAudioEncoder(
sampleRate: Int = AudioConfig.SAMPLE_RATE,
channels: Int = AudioConfig.CHANNELS,
bitrate: Int = AudioConfig.BITRATE,
complexity: Int = AudioConfig.COMPLEXITY,
) {
private val encoder = OpusEncoder(sampleRate, channels, OpusApplication.OPUS_APPLICATION_AUDIO).apply {
this.bitrate = bitrate
this.complexity = complexity
useVBR = true
// InbandFEC в Concentus удваивает стоимость кодирования (кодер считает ещё и
// резервную копию предыдущего кадра). Потери по UDP у нас ~0, а пропуски seq и так
// прячем на декодере (PLC, см. [OpusAudioDecoder.decodeLost]) — FEC только жжёт CPU.
useInbandFEC = false
packetLossPercent = AudioConfig.PACKET_LOSS_PERCENT
}
/** Переиспользуемый буфер вывода; копия отдаётся наружу только по факту кодирования. */
private val out = ByteArray(AudioConfig.MAX_DATAGRAM)
/**
* @param pcm interleaved 16-bit PCM (см. [AudioConfig.FRAME_SHORTS])
* @param offset смещение в [pcm]
* @param samplesPerChannel сэмплов на канал (см. [AudioConfig.FRAME_SAMPLES])
* @return закодированный Opus-пакет
*/
fun encode(pcm: ShortArray, offset: Int, samplesPerChannel: Int): ByteArray {
val n = encoder.encode(pcm, offset, samplesPerChannel, out, 0, out.size)
return out.copyOf(n)
}
}
/**
* Обёртка декодера Opus (Concentus). Не потокобезопасна.
*/
class OpusAudioDecoder(
sampleRate: Int = AudioConfig.SAMPLE_RATE,
channels: Int = AudioConfig.CHANNELS,
) {
private val decoder = OpusDecoder(sampleRate, channels)
/**
* @return число декодированных сэмплов на канал, либо отрицательное значение при ошибке.
*/
fun decode(
data: ByteArray,
offset: Int,
length: Int,
pcmOut: ShortArray,
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
): Int = decoder.decode(data, offset, length, pcmOut, 0, frameSamplesPerChannel, false)
/**
* Packet Loss Concealment: синтезирует «заменитель» потерянного пакета, чтобы не было
* щелчка/тишины. Вызывать, когда обнаружен пропуск seq.
*
* @return число сэмплов на канал.
*/
fun decodeLost(
pcmOut: ShortArray,
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
): Int = decoder.decode(null, 0, 0, pcmOut, 0, frameSamplesPerChannel, false)
}
@@ -0,0 +1,88 @@
package pw.binom.rayneovm.shared.audio
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OpusCodecTest {
@Test
fun `opus roundtrip compresses and decodes the expected frame`() {
val encoder = OpusAudioEncoder()
val decoder = OpusAudioDecoder()
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
val out = ShortArray(AudioConfig.FRAME_SHORTS)
var totalEncoded = 0
val frames = 10
for (f in 0 until frames) {
// 1 кГц синус, одинаковый в обоих каналах
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
val v = (Math.sin(2 * Math.PI * 1000 * i / AudioConfig.SAMPLE_RATE) * 12000).toInt().toShort()
pcm[i * 2] = v
pcm[i * 2 + 1] = v
}
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
assertTrue(payload.isNotEmpty(), "encoded payload пустой")
assertTrue(payload.size < AudioConfig.FRAME_BYTES, "Opus обязан сжимать PCM")
totalEncoded += payload.size
val decoded = decoder.decode(payload, 0, payload.size, out)
assertEquals(AudioConfig.FRAME_SAMPLES, decoded)
}
assertTrue(
totalEncoded < frames * AudioConfig.FRAME_BYTES,
"трафик Opus должен быть меньше сырого PCM",
)
}
@Test
fun `packet loss concealment produces a frame`() {
val encoder = OpusAudioEncoder()
val decoder = OpusAudioDecoder()
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
// «прогреваем» декодер одним реальным пакетом, затем прячем потерю
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
val v = (Math.sin(2 * Math.PI * 440 * i / AudioConfig.SAMPLE_RATE) * 8000).toInt().toShort()
pcm[i * 2] = v
pcm[i * 2 + 1] = v
}
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
decoder.decode(payload, 0, payload.size, ShortArray(AudioConfig.FRAME_SHORTS))
val out = ShortArray(AudioConfig.FRAME_SHORTS)
val concealed = decoder.decodeLost(out)
assertEquals(AudioConfig.FRAME_SAMPLES, concealed)
}
@Test
fun `keepalive datagram has no payload and seq -1`() {
val keepAlive = ByteArray(0)
assertEquals(-1, AudioDatagram.seq(keepAlive))
assertTrue(!AudioDatagram.hasPayload(keepAlive))
}
@Test
fun `datagram pack and seq roundtrip`() {
val payload = byteArrayOf(1, 2, 3, 4, 5)
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
val seq = 0x01020304
val len = AudioDatagram.pack(seq, payload, payload.size, out)
assertEquals(AudioDatagram.HEADER_SIZE + payload.size, len)
assertEquals(seq, AudioDatagram.seq(out, len))
assertTrue(AudioDatagram.hasPayload(out, len))
assertEquals(1, out[AudioDatagram.HEADER_SIZE].toInt())
assertEquals(5, out[AudioDatagram.HEADER_SIZE + 4].toInt())
}
@Test
fun `negative seq survives the roundtrip`() {
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
val len = AudioDatagram.pack(-1, byteArrayOf(9), 1, out)
assertEquals(-1, AudioDatagram.seq(out, len))
}
}