Compare commits

...

15 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
30 changed files with 1708 additions and 249 deletions
+137 -41
View File
@@ -6,67 +6,127 @@
--- ---
## B-1 · ShizukuProvisioner: «сетевой» путь приводит к лаунчу не той активности ## B-1 · Периодический лаунч `Mercury/.NetConfigActivity` — ПОДТВЕРЖДЁН, источник не найден
**Когда замечено:** 2026-09-27, после серии попыток запуска `free.zona` через debug-broadcast **Когда замечено:** 2026-09-27, во время тестов запуска Zona через `DEBUG_LAUNCH`.
(`DEBUG_LAUNCH`).
**Симптом:** В логах `ShizukuVdService: launchActivity pkg=com.ffalconxr.mercury.launcher **Что я видел в логах:** `ShizukuVdService: launchActivity pkg=com.ffalconxr.mercury.launcher
cls=com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity displayId=2` повторяется cls=com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity displayId=2` повторялось
каждые 5 секунд, постоянно. каждые 5 секунд, постоянно.
**Что сделано:** Broadcast уходил на очки через `adb shell am broadcast -n pw.binom.rayneovm.glasses/.debug.GlassesDebugReceiver -a pw.binom.rayneovm.glasses.action.DEBUG_LAUNCH --es pkg 'free.zona' --es cls 'ru.zona.app.android.MainActivity' --es displayId '2'`. **Что это на самом деле:** Непонятно. Это мог быть мой собственный `GlassesDebugReceiver`,
который я перепосылал несколько раз и он где-то зациклился; мог быть артефакт system-job’а;
мог быть какой-то fallback в `VmGlassesController.ensureNetworking()`.
**Стена:** Не определено, откуда берётся `pkg=com.ffalconxr.mercury.launcher/.NetConfigActivity`. **Повторно замечено:** 2026-09-27 12:28:40, `START ... NetConfigActivity from uid 2000` (shell)
Возможные источники: в логах очков во время отладки звука. `uid 2000` = shell ⇒ запускает **Shizuku UserService**
- DebugReceiver неправильно парсит extras или имеет default pkg. (наш код через Shizuku). Значит источник — где-то в нашем Shizuku-пути (не Mercury BLE).
- `ensureNetworking()`/Wi-Fi fallback в `VmService` имеет retry-loop. На аудио-grant влияния не оказал (график grant сломался по другой причине — см. B-3).
- Какое-то system-job мерцания очков стартует NetConfigActivity.
**Что НЕ проверял:** Тяжёлого анализа не делал — ловится на пол-пути. Не блокирует **Что НЕ сделано:** не остановил очки в момент пика, не нашёл источник цикла. Пользователь
другие задачи: #4 (клавиатура), #1 (биноокуляр), #3 (курсор), #5 (Accessibility) — от него утром сказал «сейчас вижу наше приложение», значит цикл или закончился, или не повторяется.
не зависят. Лаунчерт-проблема — отдельная задача.
**Обход:** Не использовать `DEBUG_LAUNCH`. Для тестов Zona запускать с телефона через штатный **Следующий шаг:** если повторится — переписать GlassesDebugReceiver с rate-limit + проверить
контроллер (`controller.startGuest(packageName)` при подключённом канале), либо руками нет ли в `VmService`/`VmGlassesController` периодического `launchActivity(Mercury)` для
через `adb shell am start -n free.zona/ru.zona.app.android.MainActivity -d 0:2`. Wi-Fi/network fallback.
--- ---
## B-2 · rayneo-mercury беспроводной `enableWirelessAdb` через BLE — не работает на RayNeo X3 Pro ## B-2 · rayneo-mercury: `enableWirelessAdb` через BLE не работал — РЕШЕНО
**Когда выяснено:** 2026-09-27, аудит RayNeoLauncher по smali (`adb pull /system/app/RayNeoLauncher`). **Когда выяснено:** 2026-09-27. **Симптом:** после ребута очков provisioning не
проходил — телефон писал «не удалось получить адрес ADB over WiFi на очках»,
Shizuku на очках не поднимался (хотя Wi-Fi и BLE у обоих были живы).
**Симптом:** **Настоящая причина — одна и она наша:** библиотека `rayneo-mercury` слала команду
``` не на тот URI.
E/MercuryResponse: Can not resolve(uri = ble.request.debug), check the request map in Message.kt 1. Очки разбирают входящие кадры по таблице `RESPONSE_MAP[uri]` (`BleResponse` из
``` `RayNeoLauncher`), и debug-ответ зарегистрирован **только** как
При попытке поднять wireless-adb через `RayNeoMercury.enableWirelessAdb()`. Команда молча `"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"}}`.
**Что сделано:** Smali-аудит `BleRequest`/`BleResponse`, поиск `RESPONSE_MAP`: Раньше «адрес не приходит» трактовалось как «очки не отвечают» — на деле очки не
- `DebugResponse` (тот же URI, что запрашивает `enableWirelessAdb`) — НЕ зарегистрирован. получали команду, поэтому и адрес было некому слать. (Сам адрес очки шлют назад как
- Мэп есть для Wi-Fi / media / netconfig — но не для debug. `ble.request.debug` + `payload.cmd="WirelessAdbAddress"` — это направление
корректно и у нас уже разобрано.)
**Стена:** Сам `ble.request.debug` принимается (`enableWirelessAdb` шлёт `payload.cmd="enableWirelessAdb"`, **Ground truth — стоковое приложение телефона `com.rayneo.mercury`:**
ответ должен прийти `WirelessAdbAddress`), но ответа нет — `DebugResponse` отсутствует. `cn.rayneo.mercury.connectivity.util.GlassesDebugCommands.enableWirelessAdb()` кладёт
То есть **принятие команды** не зависит от мэпа, но **возврат адреса** — да, и без него в `BleResponsePipeline` именно `DebugResponse(Payload(cmd="enableWirelessAdb"))`, а у
provisioning не получает `host:port`. `DebugResponse` `uri == "ble.response.debug"`. Лишние поля не читаются — обработчик
очков (`DebugKnife.observeBleWirelessDebugCommand`) матчит только `payload.cmd` и сам
вызывает `RayNeoAdbService.enableWirelessAdb(true, <текущий ssid>)`.
**Следствие:** Автономный wireless-adb через Mercury **на X3 Pro не работает**. Provisioning **Фикс:** `Commands.Debug.enableWirelessAdb()/disableWirelessAdb()` → `Uris.RESPONSE_DEBUG`;
работает только пока `adb_wifi_enabled` уже `1` (например, после ручного включения или библиотека опубликована как `0.1.1-SNAPSHOT`, в `rayneo-vm/gradle/libs.versions.toml`
shizuku-shell). На ребуте очков зависит от того, выживает ли этот глобальный setting. `mercury = "0.1.1-SNAPSHOT"`.
**Что НЕ проверял:** Ребут-тест — `adb_wifi_enabled=1` нельзя сейчас проверить, пока **Проверено на устройстве 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.
**Обход:** Пока работает. Если после ребута очков окажется, что `adb_wifi_enabled=0`, mDNS-fallback в `AdbController` остаётся как страховка.
то provisioning сразу же пробросит через `service call adb 4 i32 1 s16 'Caffeine Portable'`
от shell (через shizuku) и `settings put global adb_wifi_enabled 1` — резервный путь. **Побочный урок:** `~/.gradle/gradle.properties` содержал левый
`mercuryVersion=0.1.0-SNAPSHOT`, который перебивал проектный, — `publish` молча уходил
в старую версию. Строку убрали.
--- ---
## B-3 · Финал: гашение экрана обязательно в конце ## 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)`. Система:
```
START com.android.systemui/.media.MediaProjectionPermissionActivity from uid 10090
→ UsageStats event :23 (ACTIVITY_STOPPED) через ~30 мс
→ MainActivity: MediaProjection не выдан: resultCode=0
```
то есть grant-активити гасится, если её запускает только что перезапущенная Activity. Ручной
`KEYCODE_HOME` (уводит нашу Activity с переднего плана) «разблокировал» grant — подтверждало
гипотезу гонки.
**Фикс (вариант 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 не нужен вовсе.
**Проверено вживую 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`.**
**Проверено с руки пользователя 2026-09-27:** полный сценарий с Zona — `ON_PHONE` → `ON_GLASSES`
→ `ON_PHONE` — звук корректно переключается, повторный `ON_PHONE` больше не ломается.
**B-3 закрыт полностью.**
---
## B-4 · Финал: гашение экрана обязательно в конце
По ночным правилам — на завершении всех задач ИЛИ при невозможности продолжать — По ночным правилам — на завершении всех задач ИЛИ при невозможности продолжать —
выполняется: выполняется:
@@ -75,3 +135,39 @@ adb -s A06B4AB933C4103 shell input keyevent 223
``` ```
и проверка `mWakefulness=Dozing/Asleep`. Если не работает — через `ScreenControl` (Канал 1) / и проверка `mWakefulness=Dozing/Asleep`. Если не работает — через `ScreenControl` (Канал 1) /
Shizuku `SET_SCREEN`. Shizuku `SET_SCREEN`.
**Статус:** выполнено (mWakefulness=Dozing подтверждён в конце ночной сессии).
---
## B-5 · Телефон показывал «подключено» при мёртвом TCP-канале — РЕШЕНО
**Когда замечено:** 2026-09-27, пользователь: «телефон говорит подключено, очки говорят что нет связи».
**Причина:** у очков 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 показывал «подключено».
**Доказательство (воспроизведено):** выключили Wi-Fi очкам — через 20 c с телефона:
```
ESTAB 192.168.76.121:50438 → 192.168.76.248:8080
```
сокет так и висел, телефон даже не переподключался (те же local-порты после возврата Wi-Fi).
**Фикс (`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».
+2 -1
View File
@@ -100,7 +100,8 @@
<service <service
android:name=".VmNoopImeService" android:name=".VmNoopImeService"
android:exported="true" android:exported="true"
android:label="@string/app_name"> android:label="@string/app_name"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter> <intent-filter>
<action android:name="android.view.InputMethod" /> <action android:name="android.view.InputMethod" />
</intent-filter> </intent-filter>
@@ -7,6 +7,8 @@ import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Display import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -29,6 +31,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge 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.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.ui.GlassesScreen import pw.binom.rayneovm.glasses.ui.GlassesScreen
import pw.binom.rayneovm.glasses.vm.GlassesScreenState import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -38,6 +41,26 @@ class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */ /** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L 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` * Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2). * он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
@@ -71,15 +94,22 @@ class MainActivity : ComponentActivity() {
} }
enableEdgeToEdge() enableEdgeToEdge()
ShizukuBootstrap.attach(this) 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). // Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity хотя бы STARTED, иначе лончер кинет. // Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
// //
// handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе // handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе
// Activity в STARTED (а после диалога проекции мы как раз возвращаемся в STARTED). // Activity в RESUMED (а после диалога проекции мы как раз возвращаемся в RESUMED).
// Без него один и тот же tick запускал бы запрос повторно, новая проекция гасила // Без него один и тот же tick запускал бы запрос повторно.
// предыдущую, и AudioRecord умирал с read -2.
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
AudioBridge.requestTick.collect { tick -> AudioBridge.requestTick.collect { tick ->
if (tick > handledTick) { if (tick > handledTick) {
handledTick = tick handledTick = tick
@@ -89,6 +119,7 @@ class MainActivity : ComponentActivity() {
} }
} }
val controller = VmGlassesController.fromContext(this) val controller = VmGlassesController.fromContext(this)
this.controller = controller
setContent { setContent {
CompositionLocalProvider(VmGlassesController.Local provides controller) { CompositionLocalProvider(VmGlassesController.Local provides controller) {
@@ -112,9 +143,38 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
AudioBridge.activityResumed = true
ShizukuBootstrap.refresh() 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( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<String>, permissions: Array<String>,
@@ -136,6 +196,13 @@ class MainActivity : ComponentActivity() {
companion object { companion object {
private const val TAG = "MainActivity" 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.accessibilityservice.AccessibilityService
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/** /**
* Пассивный Accessibility-сервис GlassesApp. * Accessibility-сервис GlassesApp.
* *
* Зачем: Accessibility-сервис — единственный «легальный» способ для обычного * Зачем:
* приложения (без root/Shizuku/system-app) получить: * - живучесть процесса: система сама поднимает и перезапускает сервис;
* - живучесть процесса: систему сама поднимает и перезапускает сервис, * - «легальный» запуск Activity из фона;
* - возможность открывать Activity из фона, * - ввод текста в поле гостя на VirtualDisplay ([setText], ACTION_SET_TEXT) — IME на
* - [onAccessibilityEvent] (в будущем — для отслеживания фокуса ввода и клавиатуры, задача #4). * этих очках сменить нельзя (ROM жёстко держит LatinIME), поэтому системная
* клавиатура гостя отключается, а текст приходит из телефона сюда.
* *
* ВАЖНО: сервис НИЧЕГО не делает с чужими окнами — не читает содержимое * Регистрируется в манифесте с конфигом `res/xml/vm_accessibility_service.xml`.
* ([android:canRetrieveWindowContent] = false) и не инжектит события. Это * Активируется пользователем вручную: Настройки → Специальные возможности → RayNeo VM.
* сознательно: минимум привилегий, минимум риска сломать ввод в гостевых приложениях.
*
* Регистрируется в манифесте через [VM_ACCESSIBILITY_SERVICE] с конфигом
* `res/xml/vm_accessibility_service.xml`. Активируется пользователем вручную:
* Настройки → Специальные возможности → RayNeo VM.
*/ */
class VmAccessibilityService : AccessibilityService() { class VmAccessibilityService : AccessibilityService() {
override fun onServiceConnected() { override fun onServiceConnected() {
super.onServiceConnected() super.onServiceConnected()
instance = this
Log.i(TAG, "Accessibility-сервис подключён") Log.i(TAG, "Accessibility-сервис подключён")
// Авто-старт: если основное приложение ещё не поднято — поднимаем его. // Авто-старт: если основное приложение ещё не поднято — поднимаем его.
// Это и есть «легальный авто-запуск» из задачи #5. // Это и есть «легальный авто-запуск» из задачи #5.
@@ -37,17 +37,97 @@ class VmAccessibilityService : AccessibilityService() {
} }
override fun onAccessibilityEvent(event: AccessibilityEvent?) { override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Намеренно пусто. Слушаем только для того, чтобы сервис считался живым. // Намеренно пусто: слушаем только чтобы сервис считался живым.
// Тут позже появится логика задачи #4 (фокус поля ввода → скрыть IME гостя).
} }
override fun onInterrupt() { 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 { companion object {
private const val TAG = "VmAccessibilitySvc" 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" const val ACTION_PING = "pw.binom.rayneovm.glasses.ACCESSIBILITY_PING"
} }
@@ -4,14 +4,20 @@ import android.inputmethodservice.InputMethodService
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
/** /**
* Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay. * Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay.
* *
* Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх * Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх
* нашего приложения: InputMethodService.showInputRequestedAndUpdated → false (нет UI). * нашего приложения: [onCreateInputView] возвращает null (нет UI).
* *
* Активируется через Shizuku/binder: `settings put secure default_input_method * Заодно сервис служит каналом ввода текста: только IME держит актуальный [InputConnection]
* сфокусированного поля гостя. Сервис живёт в том же процессе, что и [VmService], поэтому
* текст с телефона ([SetText]) доставляется напрямую через [commit] — без раскладки
* и без кросс-процессного доступа к чужому InputConnection.
*
* Активируется через Shizuku: `settings put secure default_input_method
* pw.binom.rayneovm.glasses/.VmNoopImeService`. Возврат к штатному IME не предусмотрен. * pw.binom.rayneovm.glasses/.VmNoopImeService`. Возврат к штатному IME не предусмотрен.
*/ */
class VmNoopImeService : InputMethodService() { class VmNoopImeService : InputMethodService() {
@@ -21,15 +27,44 @@ class VmNoopImeService : InputMethodService() {
return null return null
} }
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
Log.i(TAG, "onStartInputView: info=$info restarting=$restarting") super.onStartInput(attribute, restarting)
// UI не показываем (onCreateInputView == null). Фокус отдаём гостю, как обычно — connection = currentInputConnection
// его кастомные EditText-ы получают inputType как обычно. Log.i(TAG, "onStartInput: conn=${connection != null} restarting=$restarting")
} }
override fun onFinishInputView(finishedInput: Boolean) {} 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 { companion object {
private const val TAG = "VmNoopIme" 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.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.media.AudioManager import android.media.AudioManager
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
@@ -38,6 +41,7 @@ import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.VmMessage import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.AppEntry import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll import pw.binom.rayneovm.shared.CursorScroll
@@ -45,7 +49,11 @@ import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.ShizukuStatus 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 на стороне очков. * Foreground service на стороне очков.
@@ -73,19 +81,33 @@ class VmService : LifecycleService() {
private var audioCapture: GlassesAudioCapture? = null private var audioCapture: GlassesAudioCapture? = null
private var audioEnsureJob: kotlinx.coroutines.Job? = null private var audioEnsureJob: kotlinx.coroutines.Job? = null
/** Opus-кодер: используется только из аудио-пампа, поэтому не нужна синхронизация. */
private val audioEncoder = OpusAudioEncoder()
/** seq аудио-датаграмм; оборачивается через переполнение Int — это нормально. */
private var audioSeq = 0
@Volatile @Volatile
private var audioMode: AudioMode = AudioMode.ON_GLASSES private var audioMode: AudioMode = AudioMode.ON_GLASSES
/** /**
* Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет. * Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
* При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть, * [AudioRecord] и Opus-кодером.
* чем полнота (иначе задержка растёт безгранично). *
* Ёмкость — запас на джиттер планировщика/GC/кодера; в норме очередь почти пуста, так что
* на задержку это не влияет. DROP_OLDEST остаётся аварийным клапаном, но при ёмкости 12
* (120 мс) срабатывает куда реже, чем при 4 (40 мс), где любой чих кодировщика рождал
* слышимый «провал» (кадр выкидывался до присвоения seq — телефон этого даже не замечал).
*/ */
private val audioFrames = Channel<ByteArray>( private val audioFrames = Channel<ShortArray>(
capacity = 16, capacity = 12,
onBufferOverflow = BufferOverflow.DROP_OLDEST, onBufferOverflow = BufferOverflow.DROP_OLDEST,
) )
/** Диагностика потерь на очках: сколько кадров сняли с AudioRecord и сколько закодировали. */
private val audioCaptured = java.util.concurrent.atomic.AtomicInteger()
private val audioEncoded = java.util.concurrent.atomic.AtomicInteger()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
@@ -100,6 +122,7 @@ class VmService : LifecycleService() {
startAudioServer() startAudioServer()
startAudioPump() startAudioPump()
registerMdns() registerMdns()
registerScreenReceiver()
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся // Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
// или нам выдали permission) — чтобы телефон не опрашивал вручную. // или нам выдали permission) — чтобы телефон не опрашивал вручную.
lifecycleScope.launch { lifecycleScope.launch {
@@ -151,6 +174,19 @@ class VmService : LifecycleService() {
*/ */
private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null) 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() { private fun startServer() {
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH) server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
@@ -160,19 +196,26 @@ class VmService : LifecycleService() {
handleIncoming(connId, json) handleIncoming(connId, json)
}, },
onConnected = { connId -> onConnected = { connId ->
Log.i(TAG, "Phone connected: $connId") liveConns.add(connId)
Log.i(TAG, "Phone connected: $connId (всего сессий: ${liveConns.size})")
currentConn.set(connId) currentConn.set(connId)
// Переподключение: снять залипшее «Нет связи с телефоном». // Переподключение: снять залипшее «Нет связи с телефоном».
controller.onPhoneConnected() controller.onPhoneConnected()
// И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа). // И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа).
lifecycleScope.launch { sendShizukuStatus(connId) } lifecycleScope.launch { sendShizukuStatus(connId) }
// ...и реальное состояние экрана (для кнопки-тумблера на телефоне).
lifecycleScope.launch { sendScreenState(connId) }
}, },
onDisconnected = { connId -> onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId") liveConns.remove(connId)
if (currentConn.compareAndSet(connId, null)) { Log.i(TAG, "Session closed: $connId (осталось: ${liveConns.size})")
// «Push»-сессию переводим на любую живую, если ушла именно она.
if (currentConn.get() == connId) {
currentConn.set(liveConns.firstOrNull())
}
// Экран гасим ТОЛЬКО когда не осталось ни одной сессии.
if (liveConns.isEmpty()) {
controller.setState(GlassesScreenState.NoConnection) controller.setState(GlassesScreenState.NoConnection)
} else {
Log.i(TAG, "Ignoring disconnect of stale session $connId")
} }
}, },
) )
@@ -218,8 +261,20 @@ class VmService : LifecycleService() {
applyAudioMode(msg.mode) applyAudioMode(msg.mode)
} }
is ScreenControl -> { is ScreenControl -> {
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}") val wantOn = !msg.sleep
controller.setScreen(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 -> { is SetText -> {
Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}") Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}")
@@ -230,6 +285,10 @@ class VmService : LifecycleService() {
Log.i(TAG, "Shutdown") Log.i(TAG, "Shutdown")
controller.stopGuest() controller.stopGuest()
} }
is ForceStopAll -> {
Log.i(TAG, "ForceStopAll")
controller.forceStopAll()
}
is GetAppList -> { is GetAppList -> {
Log.i(TAG, "GetAppList") Log.i(TAG, "GetAppList")
val apps = listInstalledLaunchableApps() val apps = listInstalledLaunchableApps()
@@ -250,20 +309,26 @@ class VmService : LifecycleService() {
// ---------------- Аудио-мост (задача #2) ---------------- // ---------------- Аудио-мост (задача #2) ----------------
/** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */ /**
* UDP-сервер аудио. Телефон шлёт сюда keepalive/подписку, мы запоминаем адрес
* источника (`ip:port`) как пира и отвечаем Opus-датаграммами.
*
* IP телефона НЕ константа — он берётся из адреса входящей датаграммы. В реальной жизни
* очки подключены к точке доступа самого телефона, адреса меняются от сессии к сессии.
*/
private fun startAudioServer() { 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) { lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try { try {
audioServer!!.accept( audioServer!!.accept(
onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ }, onMessage = { _, _ -> /* keepalive/подписка: содержимое не важно, важен источник */ },
onConnected = { connId -> onConnected = { connId ->
audioSessions.add(connId) audioSessions.add(connId)
Log.i(TAG, "Audio client connected: $connId") Log.i(TAG, "Audio peer connected: $connId")
}, },
onDisconnected = { connId -> onDisconnected = { connId ->
audioSessions.remove(connId) audioSessions.remove(connId)
Log.i(TAG, "Audio client disconnected: $connId") Log.i(TAG, "Audio peer disconnected: $connId")
}, },
) )
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -272,18 +337,46 @@ class VmService : LifecycleService() {
} }
} }
/** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */ /** Кодирует кадры PCM в Opus и рассылает датаграммы всем подключённым аудио-пирам. */
private fun startAudioPump() { private fun startAudioPump() {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { 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 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) { for (connId in audioSessions) {
try { try {
server.send(connId, bytes) server.send(connId, datagram)
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "audio send failed: ${t.message}") 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
}
} }
} }
} }
@@ -307,36 +400,35 @@ class VmService : LifecycleService() {
} }
/** /**
* Пока режим ON_PHONE и захват не поднят — периодически пробуем снова. * Пока режим ON_PHONE и захват не поднят — доводим его до старта.
* *
* Зачем цикл, а не один вызов: projection выдаёт MainActivity асинхронно, а `RECORD_AUDIO` * Проекцию запрашиваем [requestProjection] **один раз** (если её ещё нет). Повторный запрос —
* может быть ещё не выдан (provisioning телефона выдаёт его через adb). Разовая попытка * только если проекция так и не появилась за [AUDIO_RETRY_MS]. Каждый запрос поднимает
* «выстреливает» только когда всё готово; цикл с интервалом [AUDIO_ENSURE_MS] дожимает. * системную grant-активити, а её нельзя запускать, пока наша Activity только-только
* Частый ре-запрос projection вреден: новая проекция гасит предыдущую — отсюда read -2. * перезапускается — система такие запуски отменяет (`resultCode=0`). Поэтому прежний цикл с
* ре-запросом каждые 2.5 c сам себя и ломал.
*
* Полученную проекцию НЕ гасим ([GlassesAudioCapture.stop] не трогает проекцию): она живёт
* до конца процесса и переиспользуется на все последующие ON_PHONE.
*/ */
private fun ensureAudioCaptureLoop() { private fun ensureAudioCaptureLoop() {
audioEnsureJob?.cancel() audioEnsureJob?.cancel()
audioEnsureJob = lifecycleScope.launch { audioEnsureJob = lifecycleScope.launch {
var fails = 0 if (AudioBridge.projection.value == null) requestProjection()
var waited = 0L
while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) { while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
val projection = AudioBridge.projection.value val projection = AudioBridge.projection.value
if (projection == null) { if (projection != null && startAudioCapture(projection)) {
requestProjection()
} else if (startAudioCapture(projection)) {
Log.i(TAG, "аудио-захват поднят") Log.i(TAG, "аудио-захват поднят")
break break
} else {
// Захват не поднялся (нет RECORD_AUDIO / проекция мертва). Не дёргаем
// новую проекцию сразу — даём провижинингу/системе время; после
// нескольких неудач берём свежую проекцию.
fails++
if (fails >= 3) {
Log.w(TAG, "захват не поднимается ($fails) — берём новую проекцию")
AudioBridge.clearProjection()
fails = 0
}
} }
kotlinx.coroutines.delay(AUDIO_ENSURE_MS) kotlinx.coroutines.delay(AUDIO_ENSURE_MS)
waited += AUDIO_ENSURE_MS
if (projection == null && waited >= AUDIO_RETRY_MS) {
Log.w(TAG, "проекции всё ещё нет — повторный запрос")
requestProjection()
waited = 0L
}
} }
} }
} }
@@ -345,7 +437,10 @@ class VmService : LifecycleService() {
if (audioCapture != null) return true if (audioCapture != null) return true
val capture = GlassesAudioCapture( val capture = GlassesAudioCapture(
projection = projection, projection = projection,
sink = { bytes -> audioFrames.trySend(bytes) }, sink = { bytes ->
audioCaptured.incrementAndGet()
audioFrames.trySend(bytes)
},
onStopped = { lifecycleScope.launch { onCaptureDied() } }, onStopped = { lifecycleScope.launch { onCaptureDied() } },
) )
return if (capture.start(lifecycleScope)) { return if (capture.start(lifecycleScope)) {
@@ -373,15 +468,18 @@ class VmService : LifecycleService() {
private fun requestProjection() { private fun requestProjection() {
AudioBridge.requestProjection() AudioBridge.requestProjection()
// MainActivity обычно уже жив (HOME), но если нет — поднимаем: запустить // MainActivity обычно уже жива и в RESUMED — тогда она сама обработает tick, и трогать
// ActivityResult-лончер иначе некому. // её нельзя: `startActivity` в этот момент гоняется с запуском consent-активити и
runCatching { // приводит к отмене grant (resultCode=0). Поднимаем Activity только если она не наверху.
startActivity( if (!AudioBridge.activityResumed) {
Intent(this, MainActivity::class.java).apply { runCatching {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(
} Intent(this, MainActivity::class.java).apply {
) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") } }
)
}.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") }
}
} }
private fun setOutputMuted(muted: Boolean) { private fun setOutputMuted(muted: Boolean) {
@@ -413,6 +511,55 @@ class VmService : LifecycleService() {
sendShizukuStatus(connId) 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 { private fun createNotificationChannel(): String {
val channelId = "vm_service_channel" val channelId = "vm_service_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -451,6 +598,7 @@ class VmService : LifecycleService() {
audioServer = null audioServer = null
audioSessions.clear() audioSessions.clear()
unregisterMdns() unregisterMdns()
unregisterScreenReceiver()
server?.close() server?.close()
server = null server = null
instance = null instance = null
@@ -536,13 +684,12 @@ class VmService : LifecycleService() {
const val WS_PORT = 8080 const val WS_PORT = 8080
const val WS_PATH = "/vm" const val WS_PATH = "/vm"
/** Отдельный порт/путь для PCM-аудио (задача #2). Телефон знает их по константе. */ /** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
const val AUDIO_PORT = 8081
const val AUDIO_PATH = "/vm-audio"
/** Интервал ретраев подъёма аудио-захвата (пока режим ON_PHONE). */
private const val AUDIO_ENSURE_MS = 2_500L 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 NOTIFICATION_ID = 100
private const val TAG = "VmService" private const val TAG = "VmService"
@@ -582,7 +729,7 @@ class VmService : LifecycleService() {
val cap = GlassesAudioCapture( val cap = GlassesAudioCapture(
projection = p, projection = p,
sink = { /* no-op: RMS пишется внутри capture-цикла */ }, sink = { /* no-op: RMS пишется внутри capture-цикла */ },
).apply { keepProjectionOnStop = true } )
if (cap.start(svc.lifecycleScope)) { if (cap.start(svc.lifecycleScope)) {
Log.i(TAG, "DEBUG preview capture started") Log.i(TAG, "DEBUG preview capture started")
kotlinx.coroutines.delay(seconds * 1000L) kotlinx.coroutines.delay(seconds * 1000L)
@@ -1,6 +1,7 @@
package pw.binom.rayneovm.glasses.audio package pw.binom.rayneovm.glasses.audio
import android.media.projection.MediaProjection import android.media.projection.MediaProjection
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -14,11 +15,18 @@ import kotlinx.coroutines.flow.asStateFlow
* и по запросу поднимает системный интент; полученный projection кладётся сюда, а * и по запросу поднимает системный интент; полученный projection кладётся сюда, а
* [VmService] его забирает. * [VmService] его забирает.
* *
* Важно (задача #2): проекция **одна на процесс и не гасится**. Повторный grant на этом ROM
* ненадёжен — системная grant-активити отменяется, если её запускает только что перезапущенная
* Activity. Поэтому полученную проекцию мы держим живой и переиспользуем для всех последующих
* `ON_PHONE` (останавливаем только [android.media.AudioRecord], не проекцию).
*
* Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается — * Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается —
* интент сразу возвращает RESULT_OK. * интент сразу возвращает RESULT_OK.
*/ */
object AudioBridge { object AudioBridge {
private const val TAG = "AudioBridge"
private val _requestTick = MutableStateFlow(0L) private val _requestTick = MutableStateFlow(0L)
/** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */ /** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */
@@ -26,22 +34,32 @@ object AudioBridge {
private val _projection = MutableStateFlow<MediaProjection?>(null) private val _projection = MutableStateFlow<MediaProjection?>(null)
/** Текущий выданный projection (null — ещё не получен/сброшен). */ /** Текущий выданный projection (null — ещё не получен). Живёт до конца процесса. */
val projection: StateFlow<MediaProjection?> = _projection.asStateFlow() val projection: StateFlow<MediaProjection?> = _projection.asStateFlow()
/**
* `true`, пока MainActivity в RESUMED. Нужен [VmService], чтобы не дёргать
* `startActivity(MainActivity)` (это ломает grant — гонка с запуском consent-активити),
* когда Activity уже поднята и сама обработает [requestTick].
*/
@Volatile
var activityResumed: Boolean = false
/** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */ /** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */
fun requestProjection() { fun requestProjection() {
_requestTick.value = _requestTick.value + 1 _requestTick.value = _requestTick.value + 1
} }
/**
* Принять выданную проекцию. Если проекция уже есть — новую игнорируем и НЕ гасим старую:
* на приложение допускается одна проекция, а её переиспользование — основа стратегии #2.
*/
fun onProjectionGranted(projection: MediaProjection) { fun onProjectionGranted(projection: MediaProjection) {
// Медиапроекция на приложение ровно одна: получив новую, гасим старую. if (_projection.value != null) {
_projection.value?.let { runCatching { it.stop() } } Log.w(TAG, "проекция уже есть — новую игнорирую (старую не трогаю)")
return
}
_projection.value = projection _projection.value = projection
} Log.i(TAG, "проекция сохранена (живёт до конца процесса)")
fun clearProjection() {
_projection.value?.let { runCatching { it.stop() } }
_projection.value = null
} }
} }
@@ -11,21 +11,25 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.rayneovm.shared.audio.AudioConfig
/** /**
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2). * Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
* *
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`. * Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
* *
* Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS * Пишет 48 кГц stereo 16-bit кадрами ровно по [AudioConfig.FRAME_MS] мс и отдаёт их
* в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`. * в [sink] на `Dispatchers.IO` как `ShortArray` (interleaved). Кадр фиксированного
* размера нужен Opus-кодеру: он кодирует ровно [AudioConfig.FRAME_SAMPLES] сэмплов/канал.
*
* Раз в ~1 c пишется RMS в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
* *
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам * Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать. * (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
*/ */
class GlassesAudioCapture( class GlassesAudioCapture(
private val projection: MediaProjection, private val projection: MediaProjection,
private val sink: suspend (ByteArray) -> Unit, private val sink: suspend (ShortArray) -> Unit,
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */ /** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
private val onStopped: (() -> Unit)? = null, private val onStopped: (() -> Unit)? = null,
) { ) {
@@ -33,15 +37,9 @@ class GlassesAudioCapture(
private var job: Job? = null private var job: Job? = null
@Volatile private var running = false @Volatile private var running = false
/**
* `true` — при [stop] НЕ вызывать [MediaProjection.stop].
* Используется в debug-preview (`DEBUG_TEST_AUDIO_PIPE`), когда проекцию выдавать отдельно.
*/
var keepProjectionOnStop: Boolean = false
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
if (running) return true if (running) return true
val sampleRate = SAMPLE_RATE val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT val encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -50,7 +48,9 @@ class GlassesAudioCapture(
Log.w(TAG, "getMinBufferSize=$minBuf") Log.w(TAG, "getMinBufferSize=$minBuf")
return false return false
} }
val bufferSize = minBuf * 4 // Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше четырёх кадров
// (40 мс): при двух кадрах любой сбой планировщика/GC приводил к overrun и «просадке».
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 4)
val rec = try { val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection) val config = AudioPlaybackCaptureConfiguration.Builder(projection)
@@ -84,21 +84,26 @@ class GlassesAudioCapture(
running = true running = true
rec.startRecording() rec.startRecording()
job = scope.launch(Dispatchers.IO) { job = scope.launch(Dispatchers.IO) {
val buf = ByteArray(FRAME_BYTES) val frame = ShortArray(AudioConfig.FRAME_SHORTS)
var filled = 0
var frameCount = 0 var frameCount = 0
var totalBytes = 0L var totalSamples = 0L
while (isActive && running) { while (isActive && running) {
val read = rec.read(buf, 0, buf.size) val read = rec.read(frame, filled, frame.size - filled)
if (read > 0) { if (read > 0) {
try { filled += read
sink(buf.copyOf(read)) if (filled == frame.size) {
} catch (t: Throwable) { try {
Log.w(TAG, "sink упал: ${t.message}") sink(frame.copyOf())
} } catch (t: Throwable) {
totalBytes += read Log.w(TAG, "sink упал: ${t.message}")
frameCount++ }
if (frameCount % 40 == 0) { totalSamples += filled
Log.i(TAG, "capture: bytes=$totalBytes rms=${rms16(buf, read).toInt()}") frameCount++
if (frameCount % 100 == 0) {
Log.i(TAG, "capture: samples=$totalSamples rms=${rms16(frame).toInt()}")
}
filled = 0
} }
} else if (read < 0) { } else if (read < 0) {
Log.w(TAG, "AudioRecord.read ошибка: $read") Log.w(TAG, "AudioRecord.read ошибка: $read")
@@ -111,10 +116,15 @@ class GlassesAudioCapture(
onStopped?.invoke() onStopped?.invoke()
} }
} }
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize") Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, кадр=${AudioConfig.FRAME_MS}мс, buffer=$bufferSize")
return true return true
} }
/**
* Останавливает только [AudioRecord]. Проекцию НЕ гасит: она одна на процесс и
* переиспользуется (см. [AudioBridge]) — иначе следующий `ON_PHONE` требовал бы нового
* grant, который на этом ROM ненадёжен.
*/
fun stop() { fun stop() {
running = false running = false
job?.cancel() job?.cancel()
@@ -124,29 +134,19 @@ class GlassesAudioCapture(
runCatching { it.release() } runCatching { it.release() }
} }
record = null record = null
Log.i(TAG, "AudioPlaybackCapture остановлен") Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
if (!keepProjectionOnStop) {
runCatching { projection.stop() }
}
} }
private fun rms16(buf: ByteArray, len: Int): Double { private fun rms16(buf: ShortArray): Double {
if (len < 2) return 0.0 if (buf.isEmpty()) return 0.0
var sumSq = 0.0 var sumSq = 0.0
var i = 0 for (s in buf) {
while (i + 1 < len) {
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
sumSq += s.toDouble() * s sumSq += s.toDouble() * s
i += 2
} }
return Math.sqrt(sumSq / (len / 2)) return Math.sqrt(sumSq / buf.size)
} }
companion object { companion object {
private const val TAG = "GlassesAudioCapture" 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.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.mercury.shizuku.ShizukuVdClient 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.VmShizukuService
import pw.binom.rayneovm.glasses.input.TempleGesture
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.VmDisplay import pw.binom.rayneovm.shared.VmDisplay
@@ -303,20 +306,21 @@ class VmGlassesController {
* у активного гостевого дисплея. * у активного гостевого дисплея.
*/ */
fun injectText(displayId: Int, text: String): Boolean { fun injectText(displayId: Int, text: String): Boolean {
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "injectText: нет Shizuku-биндера")
return false
}
if (text.isEmpty()) return true if (text.isEmpty()) return true
return try { // Основной путь: AccessibilityService + ACTION_SET_TEXT — единственный способ
val ok = ShizukuVdBinder.injectText(binder, displayId, text) // достучаться до поля гостя на VirtualDisplay без IME (сменить IME на этих очках
if (!ok) Log.w(TAG, "injectText: binder вернул false") // нельзя, а системная клавиатура гостя отключена).
ok if (VmAccessibilityService.setText(text)) {
} catch (t: Throwable) { Log.i(TAG, "injectText: доставлено через Accessibility (len=${text.length})")
Log.w(TAG, "injectText: binder transact упал", t) return true
false
} }
// Резерв: если наш 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, ...). */ /** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
@@ -330,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]. * DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast. * Используется для тестирования через adb broadcast.
@@ -413,6 +460,43 @@ class VmGlassesController {
} }
} }
/**
* «Убить всё лишнее» на очках: 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]. * Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex].
* *
@@ -619,6 +703,21 @@ class VmGlassesController {
private const val GUARD_ACTIVITY = private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity" "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_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320 private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240 private const val GUARD_HEIGHT = 240
@@ -46,6 +46,7 @@ fun GlassesScreen(
val state by controller.state.collectAsState() val state by controller.state.collectAsState()
val controllerFrameSource by controller.frameSource.collectAsState() val controllerFrameSource by controller.frameSource.collectAsState()
val cursor by controller.cursor.collectAsState() val cursor by controller.cursor.collectAsState()
val hud = rememberStatusHudData()
val noOp = remember { NoOpFrameSource() } val noOp = remember { NoOpFrameSource() }
val frameSource = controllerFrameSource ?: noOp val frameSource = controllerFrameSource ?: noOp
@@ -95,6 +96,17 @@ fun GlassesScreen(
) )
} }
} }
// 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. Конфиг Accessibility-сервиса RayNeo VM.
Сервис — пассивный слушатель: он НЕ управляет чужими окнами и НЕ инжектит Сервис — «легальный» живучий процесс, который система поднимает/перезапускает сама,
события. Нужен только как «легальный» живучий процесс, который система через который можно открывать Activity из фона, и — главное — единственный
поднимает/перезапускает сама, и через который можно открывать Activity из фона. корректный путь ввода текста в поле гостя на VirtualDisplay без IME
(ACTION_SET_TEXT). Чужой ввод при этом НЕ инжектится.
Никаких eventTypes/feedbackType не запрашиваем сверх необходимого: eventTypes: typeWindowStateChanged|typeViewFocused — второе нужно, чтобы ловить
eventTypes="typeWindowStateChanged" — минимально нужный, чтобы сервис считался фокус поля ввода (в будущем).
активным и `onAccessibilityEvent` вызывался. canRetrieveWindowContent=false — canRetrieveWindowContent=true — иначе ACCESSIBILITY недоступен rootInActiveWindow и
содержимое окон нам не нужно, меньше привилегий. ACTION_SET_TEXT не выполнить. flagRetrieveInteractiveWindows — чтобы видеть окна
гостя на VirtualDisplay, а не только окно по умолчанию.
--> -->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged" android:accessibilityEventTypes="typeWindowStateChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault" android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="false" android:canRetrieveWindowContent="true"
android:description="@string/vm_accessibility_description" android:description="@string/vm_accessibility_description"
android:notificationTimeout="100" android:notificationTimeout="100"
android:summary="@string/vm_accessibility_summary" /> android:summary="@string/vm_accessibility_summary" />
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.adb.AdbController 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.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
@@ -24,7 +25,7 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
* и [MercuryController] (второй канал — RayNeo Mercury BLE). * и [MercuryController] (второй канал — RayNeo Mercury BLE).
*/ */
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider, class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
ShizukuProvisionerProvider, AdbControllerProvider { ShizukuProvisionerProvider, AdbControllerProvider, AccessibilityProvisionerProvider {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -34,6 +35,9 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
private val provisioner: ShizukuProvisioner by lazy { private val provisioner: ShizukuProvisioner by lazy {
ShizukuProvisioner(this, mercuryController, controller, adbController) ShizukuProvisioner(this, mercuryController, controller, adbController)
} }
private val accessibilityProvisioner: AccessibilityProvisioner by lazy {
AccessibilityProvisioner(controller, adbController)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -77,7 +81,28 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
// Разрыв BLE — новая сессия: при следующем подключении проверяем Shizuku заново. // Разрыв BLE — новая сессия: при следующем подключении проверяем Shizuku заново.
appScope.launch { appScope.launch {
mercuryController.state.collect { st -> 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() super.onTerminate()
ConnectionKeepAliveService.stop(this) ConnectionKeepAliveService.stop(this)
provisioner.close() provisioner.close()
accessibilityProvisioner.close()
controller.stop() controller.stop()
mercuryController.close() mercuryController.close()
} }
@@ -94,6 +120,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
override fun provideMercuryController(): MercuryController = mercuryController override fun provideMercuryController(): MercuryController = mercuryController
override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner
override fun provideAdbController(): AdbController = adbController override fun provideAdbController(): AdbController = adbController
override fun provideAccessibilityProvisioner(): AccessibilityProvisioner = accessibilityProvisioner
companion object { companion object {
private const val TAG = "PhoneVmApp" private const val TAG = "PhoneVmApp"
@@ -106,6 +133,11 @@ interface ShizukuProvisionerProvider {
fun provideShizukuProvisioner(): ShizukuProvisioner fun provideShizukuProvisioner(): ShizukuProvisioner
} }
/** Провайдер автовключения Accessibility-сервиса на очках для UI. */
interface AccessibilityProvisionerProvider {
fun provideAccessibilityProvisioner(): AccessibilityProvisioner
}
/** Провайдер adb-контроллера (единая точка входа для adb-команд) для UI. */ /** Провайдер adb-контроллера (единая точка входа для adb-команд) для UI. */
interface AdbControllerProvider { interface AdbControllerProvider {
fun provideAdbController(): AdbController fun provideAdbController(): AdbController
@@ -9,17 +9,25 @@ import android.os.PowerManager
import android.util.Log import android.util.Log
import pw.binom.mercury.transport.ByteTransport import pw.binom.mercury.transport.ByteTransport
import pw.binom.mercury.transport.Transports 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch 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.phone.protocol.VmPhoneController] при получении
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE. * [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
@@ -30,18 +38,31 @@ class AudioPlaybackManager(private val context: Context) {
private var track: AudioTrack? = null private var track: AudioTrack? = null
private var transport: ByteTransport? = null private var transport: ByteTransport? = null
private var streamJob: Job? = null private var streamJob: Job? = null
private var keepAliveJob: Job? = null
private var wifiLock: WifiManager.WifiLock? = null private var wifiLock: WifiManager.WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
fun start(glassesHost: String) { @Volatile
if (track != null) return // уже запущено 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 channelMask = AudioFormat.CHANNEL_OUT_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) 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() track = AudioTrack.Builder()
.setAudioAttributes( .setAudioAttributes(
@@ -59,9 +80,10 @@ class AudioPlaybackManager(private val context: Context) {
) )
.setBufferSizeInBytes(bufferSize) .setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM) .setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build() .build()
track!!.play() track!!.play()
Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize") Log.i(TAG, "AudioTrack started: $sampleRate Hz stereo, buffer=$bufferSize")
// WiFi + Wake lock // WiFi + Wake lock
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
@@ -71,36 +93,85 @@ class AudioPlaybackManager(private val context: Context) {
acquire(60 * 60 * 1000L) acquire(60 * 60 * 1000L)
} }
// Подключаемся к очкам по тому же ws://host:port/vm-audio running = true
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio" transport = Transports.udp(glassesHost, AudioConfig.UDP_PORT)
transport = Transports.wifiBytes(wsUrl, autoReconnect = true) val subscribe = ByteArray(0)
streamJob = scope.launch { streamJob = scope.launch {
var totalBytes = 0L
var frames = 0 var frames = 0
var lastSeq = -1
var lost = 0
try { try {
transport!!.connect( transport!!.connect(
onMessage = { pcm -> onMessage = { datagram ->
if (!AudioDatagram.hasPayload(datagram)) return@connect
val seq = AudioDatagram.seq(datagram)
val t = track ?: return@connect val t = track ?: return@connect
t.write(pcm, 0, pcm.size) if (lastSeq >= 0) {
totalBytes += pcm.size // Оборачивание 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++ frames++
if (frames % 40 == 0) { if (frames % 200 == 0) {
Log.i(TAG, "playing: bytes=$totalBytes rms=${rms16(pcm).toInt()}") Log.i(TAG, "playing: frames=$frames lost=$lost underruns=${t.underrunCount} lastSeq=$seq")
} }
}, },
onConnected = { Log.i(TAG, "audio connected to $wsUrl") }, onConnected = {
onDisconnected = { Log.w(TAG, "audio disconnected") }, 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) { } catch (t: CancellationException) {
// Штатная остановка (stop()/смена режима) — не ошибка. Log.i(TAG, "stream job cancelled (stop)")
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t) 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() { fun stop() {
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
running = false
keepAliveJob?.cancel(); keepAliveJob = null
streamJob?.cancel(); streamJob = null streamJob?.cancel(); streamJob = null
transport?.close(); transport = null transport?.close(); transport = null
track?.stop(); track?.release(); track = null track?.stop(); track?.release(); track = null
@@ -109,23 +180,13 @@ class AudioPlaybackManager(private val context: Context) {
Log.i(TAG, "AudioPlaybackManager stopped") Log.i(TAG, "AudioPlaybackManager stopped")
} }
private fun rms16(buf: ByteArray): Double { private companion object {
if (buf.size < 2) return 0.0 const val TAG = "AudioPlaybackMgr"
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))
}
companion object { /** Пропуск seq больше этого — оборачивание Int или сбой, а не реальные потери. */
private const val TAG = "AudioPlaybackMgr" const val MAX_GAP = 100
const val SAMPLE_RATE = 44100
/** Совпадает с [pw.binom.rayneovm.glasses.VmService.AUDIO_PORT]. */ /** Максимум кадров подряд «дотягиваем» PLC, чтобы не залипнуть на длинной потере. */
const val GLASSES_PORT = 8081 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(): * Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure():
* `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku. * `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() { private fun maybeApplyGrants() {
if (grantsAppliedInSession) return if (grantsAppliedInSession) return
@@ -110,19 +115,39 @@ class ShizukuProvisioner(
val serial = adb.ensureConnected() ?: return@launch val serial = adb.ensureConnected() ?: return@launch
Log.i(TAG, "применяю системные гранты на очках ($serial)") Log.i(TAG, "применяю системные гранты на очках ($serial)")
val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000) val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000)
val appop = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000) val appopPkg = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000)
// #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]). // PROJECT_MEDIA на Android 12+ привязан к UID, не к пакету: даже если per-package
// Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода. // стоит 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( val ime = adb.shell(
"settings put secure default_input_method " + "ime enable $GLASSES_PACKAGE/.VmNoopImeService; " +
"$GLASSES_PACKAGE/.VmNoopImeService", "ime set $GLASSES_PACKAGE/.VmNoopImeService; " +
"pm disable-user --user 0 com.android.inputmethod.latin",
timeoutMs = 15_000, timeoutMs = 15_000,
) )
Log.i( Log.i(
TAG, TAG,
"гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA=${appop.isSuccess}, IME=${ime.isSuccess}", "гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA[pkg]=${appopPkg.isSuccess}" +
"[uid=$uid]=${appopUid.isSuccess}, IME=${ime.isSuccess}",
) )
if (grant.isSuccess && appop.isSuccess && ime.isSuccess) grantsAppliedInSession = true if (grant.isSuccess && appopPkg.isSuccess && appopUid.isSuccess && ime.isSuccess) {
grantsAppliedInSession = true
}
} }
} }
@@ -187,6 +187,7 @@ class MercuryController(private val context: Context) {
} }
saveAddress(glasses) saveAddress(glasses)
_state.value = MercuryState.Connected(glasses, batteryPercent = null) _state.value = MercuryState.Connected(glasses, batteryPercent = null)
syncClock()
startStatusRefresh() startStatusRefresh()
} catch (t: Throwable) { } catch (t: Throwable) {
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}") _state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
@@ -269,6 +270,23 @@ class MercuryController(private val context: Context) {
_state.value = MercuryState.Disconnected() _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` * Настойчиво тянем статус после подключения: очки присылают `general.status`
* при изменениях, а ответ на первый запрос легко теряется (BLE-нотификация * при изменениях, а ответ на первый запрос легко теряется (BLE-нотификация
@@ -487,6 +505,9 @@ class MercuryController(private val context: Context) {
mercury.events.collect { event -> mercury.events.collect { event ->
when (event) { when (event) {
is RayNeoMercury.Event.Connected -> { is RayNeoMercury.Event.Connected -> {
// Событие приходит и при внутренних переподключениях библиотеки —
// держим часы очков синхронными на любом подъёме BLE.
syncClock()
val cur = _state.value val cur = _state.value
if (cur is MercuryState.Connecting) { if (cur is MercuryState.Connecting) {
_state.value = MercuryState.Connected(cur.device, batteryPercent = null) _state.value = MercuryState.Connected(cur.device, batteryPercent = null)
@@ -21,12 +21,14 @@ import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.CursorScroll import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.GetAppList import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.SetText import pw.binom.rayneovm.shared.SetText
import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.ShizukuStatus
@@ -55,6 +57,10 @@ class VmPhoneController(
private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null) private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null)
val glassesShizuku: StateFlow<ShizukuStatus?> = _glassesShizuku.asStateFlow() 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 var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) } private val audioManager by lazy { AudioPlaybackManager(context) }
@@ -121,6 +127,7 @@ class VmPhoneController(
onDisconnected = { onDisconnected = {
currentTransport = null currentTransport = null
_glassesShizuku.value = null _glassesShizuku.value = null
_screenOn.value = null
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек. // Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
audioManager.stop() audioManager.stop()
_state.value = ConnectionState.Disconnected _state.value = ConnectionState.Disconnected
@@ -158,6 +165,9 @@ class VmPhoneController(
is ShizukuStatus -> { is ShizukuStatus -> {
_glassesShizuku.value = msg _glassesShizuku.value = msg
} }
is ScreenState -> {
_screenOn.value = msg.on
}
is SetAudioMode -> { is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую. // Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается. // AudioCaptureService на очках стартует/останавливается.
@@ -190,6 +200,14 @@ class VmPhoneController(
send(RequestShizukuStatus) send(RequestShizukuStatus)
} }
/**
* «Убить всё лишнее» на очках: force-stop сторонних приложений + RayNeo XR-runtime
* (греющая камера) + процессы-сироты вроде scrcpy. Наши приложения и системное не трогает.
*/
suspend fun forceStopAll() {
send(ForceStopAll)
}
/** Отправляет сообщение очкам. */ /** Отправляет сообщение очкам. */
suspend fun send(msg: VmMessage) { suspend fun send(msg: VmMessage) {
val t = currentTransport ?: return val t = currentTransport ?: return
@@ -217,6 +235,8 @@ class VmPhoneController(
suspend fun setScreen(sleep: Boolean): Boolean { suspend fun setScreen(sleep: Boolean): Boolean {
if (_state.value !is ConnectionState.Connected) return false if (_state.value !is ConnectionState.Connected) return false
send(ScreenControl(sleep)) send(ScreenControl(sleep))
// Оптимистично: точное состояние очки пришлют сами (ScreenState) — возможно с задержкой.
_screenOn.value = !sleep
return true return true
} }
@@ -242,6 +262,7 @@ class VmPhoneController(
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager. * Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
*/ */
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) { 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 _audioMode.value = mode
send(SetAudioMode(mode)) send(SetAudioMode(mode))
when (mode) { when (mode) {
@@ -249,6 +270,7 @@ class VmPhoneController(
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> { pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (_state.value as? ConnectionState.Connected)?.host val host = (_state.value as? ConnectionState.Connected)?.host
if (host != null) { if (host != null) {
android.util.Log.i("VmPhoneController", "setAudioMode: ON_PHONE → audioManager.start($host)")
audioManager.start(host) audioManager.start(host)
} else { } else {
// Не подключены — команда уйдёт при следующем подключении не сразу, // Не подключены — команда уйдёт при следующем подключении не сразу,
@@ -257,6 +279,7 @@ class VmPhoneController(
} }
} }
} }
android.util.Log.i("VmPhoneController", "setAudioMode done: mode=$mode audioManager=${audioManager}")
} }
companion object { companion object {
@@ -113,6 +113,17 @@ fun GlassesStatusScreen(
fontWeight = FontWeight.Bold, 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) Channel1WebSocketSection(connection = connection, glasses = glasses)
ShizukuSection( ShizukuSection(
@@ -162,6 +173,11 @@ fun GlassesStatusScreen(
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) }, onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
) )
ForceStopSection(
enabled = connection is ConnectionState.Connected,
onForceStop = { controller.forceStopAll() },
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LaunchGlassesAppButton( LaunchGlassesAppButton(
@@ -183,7 +199,7 @@ private fun Channel1WebSocketSection(
glasses: pw.binom.rayneovm.phone.protocol.GlassesStatusInfo?, glasses: pw.binom.rayneovm.phone.protocol.GlassesStatusInfo?,
) { ) {
SectionCard(title = "Канал 1 — наше приложение (WebSocket)") { SectionCard(title = "Канал 1 — наше приложение (WebSocket)") {
StatusRow("Связь", connection.label) StatusRow("TCP/IP", connection.label)
when (connection) { when (connection) {
is ConnectionState.Connected -> glasses?.let { g -> is ConnectionState.Connected -> glasses?.let { g ->
StatusRow("Аккумулятор", g.batteryPercent?.let { "$it%" } ?: "—") StatusRow("Аккумулятор", g.batteryPercent?.let { "$it%" } ?: "—")
@@ -270,13 +286,13 @@ private fun Channel2MercurySection(
OutlinedButton(onClick = onScan) { Text("Проверить ещё раз") } OutlinedButton(onClick = onScan) { Text("Проверить ещё раз") }
} }
is MercuryState.Disconnected -> { is MercuryState.Disconnected -> {
StatusRow("Связь", "нет связи") StatusRow("BLE", "нет связи")
mercury.message?.let { Hint(it) } mercury.message?.let { Hint(it) }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onScan) { Text("Сканировать") } Button(onClick = onScan) { Text("Сканировать") }
} }
is MercuryState.Scanning -> { is MercuryState.Scanning -> {
StatusRow("Связь", "поиск… (найдено ${mercury.found.size})") StatusRow("BLE", "поиск… (найдено ${mercury.found.size})")
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if (mercury.found.isNotEmpty()) { if (mercury.found.isNotEmpty()) {
Text("Найденные устройства:", color = Color(0xFFB0B0B0), fontSize = 12.sp) Text("Найденные устройства:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
@@ -288,11 +304,11 @@ private fun Channel2MercurySection(
} }
} }
is MercuryState.Connecting -> { is MercuryState.Connecting -> {
StatusRow("Связь", "сопрягаем ${mercury.device.name ?: mercury.device.address}…") StatusRow("BLE", "сопрягаем ${mercury.device.name ?: mercury.device.address}…")
Hint("Если очки запрашивают PIN — подтвердите на них.") Hint("Если очки запрашивают PIN — подтвердите на них.")
} }
is MercuryState.Connected -> { is MercuryState.Connected -> {
StatusRow("Связь", "подключено (${mercury.device.name ?: mercury.device.address})") StatusRow("BLE", "подключено (${mercury.device.name ?: mercury.device.address})")
StatusRow("Адрес", mercury.device.address) StatusRow("Адрес", mercury.device.address)
StatusRow("Аккумулятор", mercury.batteryPercent?.let { "$it%" } ?: "—") StatusRow("Аккумулятор", mercury.batteryPercent?.let { "$it%" } ?: "—")
Spacer(modifier = Modifier.height(8.dp)) 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 @Composable
private fun GlassesWifiSection( private fun GlassesWifiSection(
status: GeneralStatus?, 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.Keyboard
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -42,6 +44,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.VmDisplay import pw.binom.rayneovm.shared.VmDisplay
@@ -71,6 +74,9 @@ fun RemoteScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Реальное состояние экрана очков (шлют сами очки) — питает кнопку-тумблер ниже.
val screenOn by controller.screenOn.collectAsState()
val connected = controller.state.collectAsState().value is ConnectionState.Connected
// Позиция курсора в координатах VirtualDisplay гостя. // Позиция курсора в координатах VirtualDisplay гостя.
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) } var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) }
// Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть» // Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть»
@@ -203,6 +209,23 @@ fun RemoteScreen(
} }
} }
// Одна кнопка-тумблер: гасит/будит экран очков. Символ отражает текущее состояние
// (его шлют очки), поэтому нажатие всегда чередует вкл/выкл корректно.
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) { if (showKeyboard) {
KeyboardDialog( KeyboardDialog(
onDismiss = { showKeyboard = false }, onDismiss = { showKeyboard = false },
@@ -210,7 +233,6 @@ fun RemoteScreen(
scope.launch { controller.sendText(text) } scope.launch { controller.sendText(text) }
showKeyboard = false showKeyboard = false
}, },
onBackspace = { scope.launch { controller.sendKey(KEYCODE_DEL) } },
onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } }, onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } },
) )
} }
@@ -131,7 +131,6 @@ fun ZonaProfile(
scope.launch { controller.sendText(text) } scope.launch { controller.sendText(text) }
showKeyboard = false showKeyboard = false
}, },
onBackspace = { scope.launch { controller.send(VmKeyEvent(keyCode = 67, action = 1)) } },
onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } }, onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } },
) )
} }
+3 -3
View File
@@ -10,10 +10,10 @@ composeBom = "2026.09.00"
shizukuApi = "13.1.5" shizukuApi = "13.1.5"
shizukuProvider = "13.1.5" shizukuProvider = "13.1.5"
navCompose = "2.10.2" navCompose = "2.10.2"
mercury = "0.1.0-SNAPSHOT" mercury = "0.1.1-SNAPSHOT"
mercuryTransport = "0.1.0-SNAPSHOT" mercuryTransport = "0.1.2-SNAPSHOT"
mercuryBinocular = "0.1.0-SNAPSHOT" mercuryBinocular = "0.1.0-SNAPSHOT"
mercuryShizuku = "0.1.0-SNAPSHOT" mercuryShizuku = "0.1.2-SNAPSHOT"
[libraries] [libraries]
# kotlinx # kotlinx
+4
View File
@@ -35,6 +35,10 @@ dependencies {
api(libs.mercury.transport) 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.kotlinx.coroutines.test)
testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter)
testImplementation(libs.kotlin.test) // need for kotlin.test.Test testImplementation(libs.kotlin.test) // need for kotlin.test.Test
Binary file not shown.
@@ -141,6 +141,18 @@ data class ScreenControl(
@SerialName("get_app_list") @SerialName("get_app_list")
data object GetAppList : VmMessage 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]). */ /** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
@Serializable @Serializable
@SerialName("request_shizuku_status") @SerialName("request_shizuku_status")
@@ -184,6 +196,18 @@ data class AppLaunchedAck(
val success: Boolean, val success: Boolean,
) : VmMessage ) : VmMessage
/**
* Состояние экрана очков: [on] = true — экран включён, false — погашен.
*
* Очки шлют его при подключении телефона и при каждом изменении (`ACTION_SCREEN_ON`/`OFF`),
* чтобы кнопка-тумблер на телефоне отражала реальное состояние, а не догадку.
*/
@Serializable
@SerialName("screen_state")
data class ScreenState(
val on: Boolean,
) : VmMessage
/** /**
* Состояние Shizuku на очках. * Состояние 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))
}
}