Compare commits

..

29 Commits

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

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

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

mercuryTransport -> 0.1.1-SNAPSHOT (клиентский WS-пинг, см. BLOCKERS B-5).
2026-09-27 15:56:49 +03:00
subochev 3808281d42 fix(audio,#2): переиспользование одного MediaProjection — устранён слом повторного ON_PHONE
- MainActivity: requestTick собирается в repeatOnLifecycle(RESUMED) + флаг AudioBridge.activityResumed
- requestProjection(): не дёргает startActivity(MainActivity), пока Activity RESUMED (гонка с grant)
- ensureAudioCaptureLoop(): запрос проекции один раз, повтор не чаще 20 c (было 2.5 c)
- AudioBridge/GlassesAudioCapture: проекция одна на процесс и не гасится, переиспользуется
- ShizukuProvisioner: PROJECT_MEDIA per-UID (на Android 12 package-level = no-op)
- отладочные логи phone-side audio; BLOCKERS.md B-3 закрыт (E2E проверен)
2026-09-27 12:49:05 +03:00
subochev c485306bc4 BLOCKERS: B-3 закрыт — захват на очках работает, E2E ждёт нажатия UI 2026-09-27 11:33:23 +03:00
subochev e3582e34ec BLOCKERS: переписан по факту (B-1 не подтверждён, B-2 — нет DebugResponse в RESPONSE_MAP, B-3 — очки на другом WiFi) 2026-09-27 11:26:40 +03:00
subochev ad4e458f5c #5 AccessibilityProvisioner: авто-включение VmAccessibilityService на очках при коннекте (по модели ShizukuProvisioner) 2026-09-27 11:22:04 +03:00
subochev 65f3f5d228 #1,#3,#4+#5 keyboards: Binocular Compose, cursor watchdog 3s, SetText + VmNoopIme + sendText 2026-09-27 11:18:52 +03:00
subochev 3ac76daca1 feat(#1,#3): binocular start screen via Binocular{}; cursor auto-hide after 3s idle 2026-09-27 06:42:26 +03:00
subochev b1efa3d462 feat(audio,#2): MediaProjection + AudioPlaybackCapture preview on glasses; AudioPath /vm-audio to phone; debug test tone; debug audio pipe; FGS mediaProjection type; AudioOutputSection UI toggle 2026-09-27 06:39:00 +03:00
subochev 2125788e25 glasses: пассивный Accessibility-сервис (авто-запуск + живучесть) + TASKS.md с планом 2026-09-27 05:46:14 +03:00
subochev 9914488258 phone: AdbController — единственная точка входа для adb-команд; screen on/off через Канал 1
- AdbController: shell/push/ensureConnected/invalidate; перед каждой командой
  проверяет соединение (echo ok) и сам поднимает wireless-adb (Mercury -> mDNS ->
  connect). Мьютекс только на подъём, длинный бутстрап не блокирует команды.
- ShizukuProvisioner переведён на AdbController (adb-код вынесен).
- Убрана неработающая UI-консоль adb (нужен только программный API).
- ScreenControl(sleep) в shared: гашение/пробуждение экрана очков их же
  Shizuku-UserService (инжект 223/224 как shell), не зависит от Mercury/adb.
- VmPhoneController.setScreen + кнопки «Экран очков» на Канале 1.
- DebugReceiver: DEBUG_ADB_SHELL (+ SET_DEEP_SUSPEND) как отладочный инструмент.
- MercuryController/AdbClient: сопутствующие правки.
2026-09-27 04:55:47 +03:00
subochev 027e2efb9c phone: rewind/forward buttons in remote screen (DPAD left/right) 2026-09-27 04:01:22 +03:00
subochev 8a0b7832c0 phone: autonomous Shizuku revive over wireless-adb; glasses screen on/off buttons
AdbClient wraps the bundled libadb.so (useLegacyPackaging -> exec from nativeLibraryDir, HOME/TMPDIR overridden, adb key injected into BuildConfig from ~/.android at build time and never committed). ShizukuProvisioner: on each BLE+WiFi connect it enables ADB-over-WiFi via Mercury, connects with mDNS fallback, pushes the Shizuku starter and runs it; runs once per BLE session, does not relaunch GlassesApp when the WS channel is already up, and exposes runShell() for arbitrary commands. DebugReceiver (DUMP) + am start --ez provision for diagnostics.

Protocol: ShizukuStatus(running/granted) + RequestShizukuStatus; glasses push it on connect and on Shizuku state change, using a passive probe() that never shows a permission dialog. Glasses kill-guard now recreates its UserService when the Shizuku binder dies instead of spamming DeadObjectException. Mercury: brightness disables auto-brightness first (was silently ignored), BLE auto-connect loop retries every 6s (fixes 'searches then stops'). New phone section 'Экран очков' with wake/sleep via adb (input keyevent 224/223).
2026-09-27 03:52:05 +03:00
subochev 59069a741e phone: keep-alive foreground service + notification perm; glasses: clear NoConnection on reconnect
- ConnectionKeepAliveService (foreground, PARTIAL_WAKE_LOCK + WIFI_MODE_FULL_HIGH_PERF,
  started in PhoneVmApp.onCreate): without it HONOR deep-sleeps the phone process on
  screen-off and the glasses lose the WebSocket link. Same mechanic as view-mate.
- MainActivity: request POST_NOTIFICATIONS so the FGS notification is visible.
- VmGlassesController.onPhoneConnected(): drop a stuck NoConnection state when the
  phone (re)connects; returns to Running if a guest is active, else NoAppSelected.
- VmService: call it from onConnected.
- stopGuest/switch: force-stop the previous guest before releasing its display
  (releasing first migrates the guest task to display 0 full-screen); VirtualDisplay
  frame source now destroys its UserService on release.
- Mercury status: retry the first pullGeneralStatus until it lands, refresh every 15s
  and on tab open; GlassesStatusScreen shows 'Ждём статус от очков…' instead of fake zeros.
- MainActivity(glasses): HOME candidate flag + finish() on a non-default display (recursion guard).
- README: Home-launcher setup/rollback section.
- BOOTSTRAP_TEST.md: post-reboot Shizuku bootstrap investigation log.
2026-09-26 13:59:54 +03:00
subochev 94f0ed28fc glasses: ignore scroll drags shorter than 96px (guest counts them as item clicks); phone: skip centroid jump on finger-count change 2026-09-25 20:27:26 +03:00
subochev 05daf3fc7b phone: delay touchpad tap click by 100 ms; second finger cancels it 2026-09-25 20:20:39 +03:00
subochev fe95d249e9 glasses: start Mercury kill guard with service; batch scroll deltas into one drag per tick 2026-09-25 20:07:42 +03:00
subochev c69e8a2e80 glasses: hold Mercury kill switch on hidden VirtualDisplay; phone: brightness/WiFi panel, two-finger touchpad scroll 2026-09-25 19:49:25 +03:00
subochev 3fce3eb3ca phone: third «Пульт» tab with on-glasses cursor; atomic click injection
- shared: VmDisplay (guest 640x480 coordinate space), CursorMove and
  CursorClick messages (CursorClick replaces the DOWN/UP pair — see below).
- glasses: cursor state + Canvas overlay drawn in both eyes, clickCursor
  injects one atomic tap at the cursor, sendKey for Back/Play/Pause.
- phone: RemoteScreen — touchpad (relative drag moves the cursor, tap
  clicks), «Назад»/«Плей»/«Пауза» buttons, «Клавиатура» stub (TODO).
- The old CursorButton(DOWN)/CursorButton(UP) pair was removed: the two
  messages were injected concurrently and could arrive out of order,
  breaking the guest's touch stream so clicks worked only once.
- tests: CursorMove/CursorClick roundtrips.
2026-09-25 18:20:56 +03:00
subochev 03f1d7dcc7 glasses: launch guest on Shizuku VirtualDisplay + binocular render
- VmShizukuService is a Shizuku UserService only; remove its <service> manifest entry (ServiceStarter instantiates it reflectively, an Android service entry causes ClassCastException)
- VirtualDisplayFrameSource: await binder via ShizukuVdClient.bindForResult(5s), expose virtualDisplayId + shizukuBinder, stop the UserService on release
- VmGlassesController: serialize acquire via Mutex, use double-buffered SurfaceTexture (DisplayManagerService rejects single-buffer), launch the guest on the VD with ShizukuVdBinder.launchActivity
- GlassesScreen: render controller.frameSource (Running) directly; drop the LaunchedEffect acquire
- DebugTestReceiver: register at runtime from MainActivity (background manifest receivers are blocked on Android 12+)
2026-09-25 17:19:49 +03:00
53 changed files with 5796 additions and 440 deletions
+173
View File
@@ -0,0 +1,173 @@
# Blockers
Сюда пишутся только те задачи/проблемы, в которых застряли. Каждая запись — задача,
что сделано, в чём стена, что проверено, варианты обхода. Записи ведутся по правилам
ночной сессии (см. `TASKS.md`, секция «Правила ночной сессии»).
---
## B-1 · Периодический лаунч `Mercury/.NetConfigActivity` — ПОДТВЕРЖДЁН, источник не найден
**Когда замечено:** 2026-09-27, во время тестов запуска Zona через `DEBUG_LAUNCH`.
**Что я видел в логах:** `ShizukuVdService: launchActivity pkg=com.ffalconxr.mercury.launcher
cls=com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity displayId=2` повторялось
каждые 5 секунд, постоянно.
**Что это на самом деле:** Непонятно. Это мог быть мой собственный `GlassesDebugReceiver`,
который я перепосылал несколько раз и он где-то зациклился; мог быть артефакт system-job’а;
мог быть какой-то fallback в `VmGlassesController.ensureNetworking()`.
**Повторно замечено:** 2026-09-27 12:28:40, `START ... NetConfigActivity from uid 2000` (shell)
в логах очков во время отладки звука. `uid 2000` = shell ⇒ запускает **Shizuku UserService**
(наш код через Shizuku). Значит источник — где-то в нашем Shizuku-пути (не Mercury BLE).
На аудио-grant влияния не оказал (график grant сломался по другой причине — см. B-3).
**Что НЕ сделано:** не остановил очки в момент пика, не нашёл источник цикла. Пользователь
утром сказал «сейчас вижу наше приложение», значит цикл или закончился, или не повторяется.
**Следующий шаг:** если повторится — переписать GlassesDebugReceiver с rate-limit + проверить
нет ли в `VmService`/`VmGlassesController` периодического `launchActivity(Mercury)` для
Wi-Fi/network fallback.
---
## B-2 · rayneo-mercury: `enableWirelessAdb` через BLE не работал — РЕШЕНО
**Когда выяснено:** 2026-09-27. **Симптом:** после ребута очков provisioning не
проходил — телефон писал «не удалось получить адрес ADB over WiFi на очках»,
Shizuku на очках не поднимался (хотя Wi-Fi и BLE у обоих были живы).
**Настоящая причина — одна и она наша:** библиотека `rayneo-mercury` слала команду
не на тот URI.
1. Очки разбирают входящие кадры по таблице `RESPONSE_MAP[uri]` (`BleResponse` из
`RayNeoLauncher`), и debug-ответ зарегистрирован **только** как
`"ble.response.debug"` → `DebugResponse`.
2. Наша `Commands.Debug.enableWirelessAdb()` слала `"ble.request.debug"` — это
направление очки→телефон.
3. Итог: кадр доходил до очков и **молча выбрасывался** в
`BleServerV2.handleMobileCommand` — не совпал ни с `"brt"`, ни с `RESPONSE_MAP`.
Ни обработки, ни ошибки, ни лога.
Полный JSON, который уходил: `{"uri":"ble.request.debug","payload":{"cmd":"enableWirelessAdb"}}`.
Раньше «адрес не приходит» трактовалось как «очки не отвечают» — на деле очки не
получали команду, поэтому и адрес было некому слать. (Сам адрес очки шлют назад как
`ble.request.debug` + `payload.cmd="WirelessAdbAddress"` — это направление
корректно и у нас уже разобрано.)
**Ground truth — стоковое приложение телефона `com.rayneo.mercury`:**
`cn.rayneo.mercury.connectivity.util.GlassesDebugCommands.enableWirelessAdb()` кладёт
в `BleResponsePipeline` именно `DebugResponse(Payload(cmd="enableWirelessAdb"))`, а у
`DebugResponse` `uri == "ble.response.debug"`. Лишние поля не читаются — обработчик
очков (`DebugKnife.observeBleWirelessDebugCommand`) матчит только `payload.cmd` и сам
вызывает `RayNeoAdbService.enableWirelessAdb(true, <текущий ssid>)`.
**Фикс:** `Commands.Debug.enableWirelessAdb()/disableWirelessAdb()` → `Uris.RESPONSE_DEBUG`;
библиотека опубликована как `0.1.1-SNAPSHOT`, в `rayneo-vm/gradle/libs.versions.toml`
`mercury = "0.1.1-SNAPSHOT"`.
**Проверено на устройстве 2026-09-27 16:22** (очки перезагружены, `adb_wifi_enabled=0`,
по USB ничего не активировал — всё делает телефон):
1. `ble.response.debug` → `DebugResponse dispatched` → `RayNeoAdbService: alwaysAllow=true,
bssid=O2 5` → `open wireless adb success: true` → на телефон вернулся адрес
`192.168.76.248:41395`. Первый ответ бывает `ip:-1` (порт ещё не назначен), через ~0.6 c
приходит второй, валидный.
2. Телефон: `ShizukuProvisioner: starting server... shizuku_starter exit with 0` →
`Shizuku поднят` (`pidof shizuku_server` = 4104).
3. `AccessibilityProvisioner` включил `VmAccessibilityService` через тот же adb.
mDNS-fallback в `AdbController` остаётся как страховка.
**Побочный урок:** `~/.gradle/gradle.properties` содержал левый
`mercuryVersion=0.1.0-SNAPSHOT`, который перебивал проектный, — `publish` молча уходил
в старую версию. Строку убрали.
---
## B-3 · Звук end-to-end — РЕШЕНО
**Когда замечено:** 2026-09-27, E2E-проверка после восстановления общей сети «O2 5».
**Симптом:** звук на телефон работал только в первый раз; после `ON_GLASSES` → `ON_PHONE` тишина
и на очках, и на телефоне.
**Корень (найден по логам 2026-09-27):** не сеть и не телефон, а **grant MediaProjection на очках**.
`ensureAudioCaptureLoop` ре-запрашивал проекцию каждые 2.5 c, а `requestProjection()` каждый раз
дёргал `startActivity(MainActivity, NEW_TASK|SINGLE_TOP)`. Система:
```
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 · Финал: гашение экрана обязательно в конце
По ночным правилам — на завершении всех задач ИЛИ при невозможности продолжать —
выполняется:
```
adb -s A06B4AB933C4103 shell input keyevent 223
```
и проверка `mWakefulness=Dozing/Asleep`. Если не работает — через `ScreenControl` (Канал 1) /
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».
+140
View File
@@ -0,0 +1,140 @@
# Тест автономного автозапуска GlassesApp (HOME + adb-бутстрап Shizuku) — ВЫВОДЫ
Цель: доказать, что GlassesApp может подниматься и оживлять Shizuku после холодной
перезагрузки **без Mercury-лаунчера и без USB-компьютера**.
Устройство: RayNeo X3 Pro, adb serial `A06B4AB933C4103`.
Дата экспериментов: 2026-09-25/26.
---
## ИТОГОВЫЙ ВЫВОД (кратко)
1. **Автозапуск через HOME не работает.** Наш пакет можно назначить дефолтным HOME
(`cmd package set-home-activity pw.binom.rayneovm.glasses/.MainActivity`, `isDefault=true`),
но при загрузке ROM-патч system_server всё равно поднимает **свою**
`com.ffalconxr.mercury.launcher/.home.LauncherHomeActivity`. Ручной HOME-intent
(`am start -a MAIN -c HOME`) открывает наш `MainActivity` правильно — то есть наш
домашний экран рабочий, но boot-резолв перехвачен Mercury.
2. **Mercury-лаунчер убрать нельзя.** `pm disable-user com.ffalconxr.mercury.launcher`
применяется (`enabled=0`), но system_server всё равно стартует Mercury при буте
(PID uid system), и **его BLE продолжает работать** (телефон по Bluetooth соединяется).
Значит: убийца `BackgroundAppManager` присутствует всегда → стража
NetConfigActivity-на-скрытом-дисплее **обязательна**. Отключение лаунчера проблему не решает.
3. **BLE жив всегда.** Раз Mercury стартует при буте в любом случае, Channel 2 (BLE:
запуск приложения, яркость, WiFi, статус) доступен всегда. Это надёжный канал.
4. **`com.rayneo.mercury.app=true`** у нас уже стоит в манифесте, но **иммунитета от
убийцы не даёт** — нас всё равно убивало.
## Про adb-бутстрап Shizuku после ребута
| Способ | Результат |
|--------|-----------|
| legacy plain-TCP `persist.adb.tcp.port` / `service.adb.tcp.port` из shell (uid 2000) | ❌ SELinux запрещает (`Unable to set property ... adbd_config_prop, u:r:shell:s0`) |
| Wireless-ADB `settings put global adb_wifi_enabled 1` | ⚠️ включается, но сбрасывается ребутом и требует подключённого WiFi; при выключенном WiFi очки полностью пропадают из сети |
| «Второй adbd» на `127.0.0.1:8872` | ❌ это не adbd, а Bluetooth HCI-snoop (`btsnoop`), uid android.uid.bluetooth |
| Единственный настоящий adbd | штатный `adbd --root_seclabel=u:r:su:s0` (uid shell), слушает USB/WiFi |
**Вывод:** автономного сетевого бутстрапа Shizuku без WiFi у нас нет. После ребута
очки оживают либо через USB, либо через WiFi+wireless-adb (если сеть есть).
Self-bootstrap (встроенный adb-клиент в приложении + авторизованный ключ) возможен,
но только когда сеть уже поднята, т.е. холодный старт им не закрыть.
## RayDesk (github.com/Quad-Labs/RayDesk) — почему «без приседаний»
RayDesk — Moonlight-клиент: стримит рабочий стол ПК в **свой** GL-рендер. Он:
- НЕ создаёт VirtualDisplay, НЕ использует Shizuku, НЕ запускает чужие Activity;
- «оба глаза» = свой `TextureView` (левый) + Mercury SDK `MirroringView` (правый);
- использует проприетарный Mercury SDK (`BaseMirrorActivity`, `TempleAction`), нужен
developer access от RayNeo.
Их подход **неприменим** к нашей задаче: мы запускаем чужие приложения на VD — это
принципиально другой сценарий. Заимствовать нечего (наш meta-data уже стоит).
## Остаётся рабочих варианта
- **A (текущий):** Mercury живёт как сервис (BLE + убийца), стража NetConfigActivity
держит гостя, Shizuku держим (USB / wireless-adb при наличии WiFi).
- **B:** встроить в приложение adb-клиент (dadb) + один раз авторизовать наш ключ —
поднимает Shizuku по сети, когда WiFi есть (не закрывает холодный старт без сети).
---
## Журнал (детали по этапам)
### Этап A/B — HOME после ребута
- Назначили наш пакет дефолтным HOME. После ребута `topResumedActivity=`
`com.ffalconxr.mercury.launcher/.home.LauncherHomeActivity` — нас система не подняла.
- `resolve-activity` при этом указывает на нас (`isDefault=true`) — то есть boot идёт
мимо обычного HOME-резолва (ROM-патч).
### Этап C — adb после ребута
- `settings put global adb_wifi_enabled 1` ✅ (запись из shell проходит).
- `setprop service.adb.tcp.port` / `persist.adb.tcp.port` ❌ SELinux.
- boot-лог: `AdbDebuggingManager: Not connected to any wireless network. Not enabling adbwifi.`
- Wireless-adb поднялся по WiFi: `adb connect 192.168.76.248:40481` → `device`.
### Этап D — WiFi off (ложная «тыква»)
- При `svc wifi disable` очки пропали из сети полностью (ping unreachable) — wireless-adb
живёт на WiFi-интерфейсе, loopback снаружи недоступен. Внутренний loopback не проверялся.
### Этап E — отключение Mercury
- `pm disable-user` → `enabled=0`, но после ребута Mercury запущен и держит стол;
телефон по BLE соединился. Откат: `pm enable --user 0 com.ffalconxr.mercury.launcher`
выполнен (`enabled=1`).
### Ложная зацепка
- `127.0.0.1:8872` (uid 1002) — при коннекте отдаёт поток с магией `btsnoop` → это
Bluetooth HCI-логгер, не adbd. ADB-хендшейк не проходит.
---
## Этап F — автономный подъём Shizuku С ТЕЛЕФОНА (успех)
### Механизм включения wireless-adb на очках
- Нужно **два** действия (иначе TLS-сервер не поднимается):
1. `IAdbManager.allowWirelessDebugging(true, ssid)` — транзакция binder `adb` №4,
добавляет доверенную сеть (`adb_temp_keys.xml` + запись wifiAP/bssid).
2. `settings put global adb_wifi_enabled 1` — adbd стартует
`TlsServer running on port <random>` и публикует mDNS `_adb-tls-connect._tcp`.
- Порт — **случайный**; узнать: binder-транзакция `adb` №10 (`getAdbWirelessPort`).
- Shell (uid 2000) вправе делать оба вызова — root не нужен.
### Критично: wireless-adb сам гаснет
- Как только очки теряют ту WiFi-сеть, к которой были привязаны, `adb_wifi_enabled`
сбрасывается в 0, порт становится `-1`. Поэтому при каждом бутстрапе его надо
перевключать (через Mercury `enableWirelessAdb`, который и вызывает оба шага).
### Авторизация без сопряжения
- Встроенный adb-ключ рабочей машины уже лежит в `/data/misc/adb/adb_keys` очков.
`AdbDebuggingManager.addUserKeysToKeyStore()` подмешивает user-ключи в wireless-keystore,
поэтому при connect по TLS ключ принимается без pairing-кода. Проверено.
### Реализация в PhoneApp
- `jniLibs/arm64-v8a/libadb.so` — AOSP adb (префилд LADB, Apache-2.0);
`useLegacyPackaging = true` → распаковывается в `nativeLibraryDir`, откуда
разрешён `exec` (W^X запрещает exec из filesDir/cacheDir — первая ошибка была `EACCES`).
- Ключ `~/.android/adbkey` зашивается в `BuildConfig` на сборке (в репо не коммитится).
- `AdbClient`: HOME=filesDir/.android (ключ кладём и в `.android/.android`),
TMPDIR=cacheDir, `ANDROID_USER_CONFIG`=HOME.
- `ShizukuProvisioner`: Mercury адрес → mDNS fallback → `start-server` → `connect` →
`pidof shizuku_server` → push `assets/shizuku/starter` → `chmod` →
`starter --apk=$(pm path moe.shizuku.privileged.api)` → проверка `pidof` → `appMarketLaunch`.
### Проверено на устройстве (через TCP-релей ПК, т.к. телефон и очки были в разных сетях)
```
forceWithAddress(192.168.76.142:39998)
exec: -s ... shell /data/local/tmp/shizuku_starter --apk=.../base.apk
info: shizuku_server pid is 4342
pidof shizuku_server -> '4342'
Shizuku поднят
```
Диагностика: `am broadcast -a pw.binom.rayneovm.phone.DEBUG_PROVISION [--es address ip:port]`
или `am start -n pw.binom.rayneovm.phone/.MainActivity --ez provision true [--es address ip:port]`.
### Остаётся проверить «в бою»
- Реальный сценарий: телефон держит хотспот, очки к нему подключены, BLE соединён →
`enableWirelessAdb` отдаёт адрес (или mDNS), телефон поднимает Shizuku сам.
+32
View File
@@ -114,6 +114,38 @@ adb -s <glasses-serial> install -r app-glasses-vm/build/outputs/apk/debug/app-gl
adb -s <phone-serial> install -r app-phone-vm/build/outputs/apk/debug/app-phone-vm-debug.apk adb -s <phone-serial> install -r app-phone-vm/build/outputs/apk/debug/app-phone-vm-debug.apk
``` ```
## GlassesApp как Home (лаунчер)
На RayNeo X3 Pro `BOOT_COMPLETED` ненадёжен, поэтому штатный автозапуск — сделать GlassesApp домашним экраном: система поднимает HOME при каждой загрузке. В манифесте у `MainActivity` уже есть intent-filter `MAIN` + `HOME` + `DEFAULT`, но **дефолтным он не назначается автоматически** — это отдельная команда, ставится вручную.
Назначить нашим приложением (выполнять осознанно; пока не сделано):
```bash
adb -s <glasses-serial> shell cmd package set-home-activity pw.binom.rayneovm.glasses/.MainActivity
```
Вернуть штатный лаунчер RayNeo обратно:
```bash
adb -s <glasses-serial> shell cmd package set-home-activity com.ffalconxr.mercury.launcher/home.MainActivity
```
> Компонент записывается через `/` (package/class), а не через `/.` — именно так его принимает `set-home-activity` на этой прошивке.
Проверить, кто сейчас дефолтный HOME:
```bash
adb -s <glasses-serial> shell cmd package resolve-activity -a android.intent.action.MAIN -c android.intent.category.HOME
```
Показать всех кандидатов в HOME:
```bash
adb -s <glasses-serial> shell cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.HOME
```
Аварийный выход, если домашний экран вообще перестал открываться (например, после `pm disable-user` не того пакета): переключиться на другой зарегистрированный HOME временно тем же `set-home-activity` (у нас в системе есть `com.teslacoilsw.launcher`, Nova) либо снять отключение через `cmd package enable-user`.
## Smoke-test (проверено) ## Smoke-test (проверено)
1. Запустить `GlassesVmApp` на очках → VmService стартует, `netstat -tlnp` показывает `*:8080`. 1. Запустить `GlassesVmApp` на очках → VmService стартует, `netstat -tlnp` показывает `*:8080`.
+183
View File
@@ -0,0 +1,183 @@
# 1 · Биноокулярный стартовый экран
Стартовый экран (тот когда не запущено ничего на виртуальных мониторах) отображается
криво, не бинокулярно. Он тоже должен быть бинокулярным.
**Причина:** `StatusOverlay` в `app-glasses-vm/.../ui/GlassesScreen.kt` — обычный Compose-оверлей,
центрируется по всей ширине 1280px, поэтому попадает на стык глаз и рисуется ОДИН раз, а не в обеих
половинах. Тот же приём уже используется в `CursorOverlay` (руками: `x` и `x + half`).
**Решение (согласовано 2026-09-27):**
- Перенести в **rayneo-mercury** удобную view-mate-функцию `Binocular` из
`view-mate/app-glasses/.../ui/ViewUtils.kt` (`fun Binocular`, `BinocularSize`, `BinocularScreenSide`,
`enum Side`, `ScreenSize`, `getValue`-extension). Она делает `Row` из двух `Column(weight(1f))`,
каждый прогоняет весь `content` в своей половине, а `BinocularScreenSize` сообщает композитору
размер одного глаза (W/2 × H).
- Использовать `Binocular { ... }` **только для простых экранов** (Starting / NoConnection /
NoAppSelected / Error / ShizukuMissing).
- **НЕ трогать** GL-сюрфейс (`BinocularRendererView` — он сам бинокулярит на 1280 и кладётся ВНЕ
`Binocular`) и **НЕ трогать `CursorOverlay`** — там хитрая отрисовка, оставить как есть.
**Где переносить:** модуль rayneo-mercury. Кандидаты: `:binocular-renderer` либо новый маленький
`:compose-binocular`. Решить при реализации (по зависимостям — `Binocular` не нужен GL).
---
# 2 · Проброс звука с виртуального дисплея на телефон
Добавить возможность проброса звука с виртуального дисплея на телефон. Переключатель: через телефон
или с очков. Сначала механика, потом кнопка в мобильном приложении.
**Что уже есть (но не доделано):**
- `AudioCaptureService` (очки) — НЕ объявлен в манифесте, никто не вызывает; `SetAudioMode` на
очках только логируется (`VmService.kt`). Сейчас в MIC-fallback, а нужен `AudioPlaybackCapture`.
- Серверного endpoint `/vm-audio` НЕТ (`WifiServerTransport` регистрирует только `/vm`).
- `AudioPlaybackManager` (телефон) написан, но никем не вызывается; `AudioPlaybackService` — заглушка.
- UI-переключателя нет; `controller.setAudioMode` не вызывается.
**Решение (согласовано 2026-09-27):**
- **MediaProjection ОБЯЗАТЕЛЕН** для `AudioPlaybackCapture` (`CAPTURE_AUDIO_OUTPUT` — privileged,
Shizuku не даёт; Accessibility тоже не даёт). НО диалог можно обойти:
`MediaProjection` проверяет AppOps **`PROJECT_MEDIA`** → `cmd appops set <pkg> PROJECT_MEDIA allow`
отключает диалог НАВСЕГДА. Выполнять через Shizuku (один раз, либо при каждом соединении, если
appop сбрасывается системой).
- ON_PHONE: захват `AudioPlaybackCapture` на очках → стрим на телефон → `AudioPlaybackManager`
играет. **Обязательно глушить динамик очков** (`STREAM_MUSIC` → 0 / mute), иначе двойной звук.
- ON_GLASSES: стоп захвата, вернуть звук очков.
- Захватывается ВСЁ системное аудио очков, включая звук гостя — так и надо.
- **Переключатель — на экране общего статуса очков** (`GlassesStatusScreen`), не в пульте.
**Открытые технические вопросы (проверить на девайсе):**
- Работает ли `cmd appops set ... PROJECT_MEDIA allow` от shell на RayNeo X3 Pro (SELinux)?
- Cбрасывается ли `PROJECT_MEDIA` appop при перезапуске/обновлении? Если да — выставлять при каждом
соединении.
- Глушение: `setStreamVolume(STREAM_MUSIC, 0)` глобально; вернуть прежнее значение в ON_GLASSES.
**Риск:** самая большая задача. Начинать первой.
---
# 3 · Автоскрытие курсора
Когда приложение на виртуальном дисплее запущено, отображается положение мышки (жёлтая точка).
Когда она долго не двигается — скрывать на очках.
**Механики нет вообще.** Курсор задаётся в `moveCursor` (`VmGlassesController.kt`), скрывается только
в `stopGuest`.
**Решение (согласовано 2026-09-27):**
- Watchdog: **3 секунды** после последнего действия → скрыть курсор.
- «Движением» считать **любое действие с тачпадом**: move / click / scroll (`moveCursor` /
`clickCursor` / `scrollCursor`) — все сбрасывают таймер и показывают курсор.
- После скрытия при следующем действии курсор появляется (можно на новой позиции — не принципиально).
---
# 4 · Клавиатура
1. Когда на очках поле ввода в фокусе — НЕ показывать клавиатуру на очках.
2. Когда на телефоне тыкаю кнопку клавиатуры — ввести текст, и всё, что вводится, уходит на очки.
**Текущее состояние:**
- На телефоне есть рабочий `KeyboardDialog`, но он не подключён к навигации (ZonaProfile — мёртвый
экран). В `RemoteScreen.kt` кнопка «Клавиатура» **задизейблена**.
- На очках своей клавиатуры нет. Но когда гости (Zona) получает фокус в поле ввода, он показывает
СВОЙ системный IME **поверх нашего приложения** (один экземпляр на display 0, поэтому не
бинокулярный). Это и есть проблема 4.1.
**Решение (согласовано 2026-09-27):**
- **4.1:** написать крошечный IME-заглушку (`VmNoopImeService`, 0 UI) **внутри GlassesApp** и
поставить его дефолтным IME **навсегда** через Shizuku (`settings put secure default_input_method
<наш IME>`). Обратно НЕ возвращать (пользователю не важно). Никакого диалога выбора IME.
- **4.2:** ввод с телефона — инжект Unicode в `KeyEvent` (через Shizuku binder, `injectText(String)`),
чтобы работали **русский и английский**. Новое сообщение `SetText` в `:shared` `VmMessage`.
Кнопка клавиатуры — в `RemoteScreen` (там сейчас заглушка).
- В перспективе: `onAccessibilityEvent` из #5 может помочь прятать IME гостя (см. #5).
**Открытые вопросы (проверить):**
- Пустит ли RayNeo выбрать наш IME дефолтным (`settings put secure default_input_method`) через shell?
- Нужны ли отдельные кнопки Backspace/Enter на телефонной клавиатуре (обычно да).
---
# 5 · Accessibility-сервис (авто-запуск + фоновый процесс) — НЕ ЗАКРЫТА
Рассмотреть возможность добавить сервис в режиме Accessibility: принимать наши команды, делать всё в
фоне (mDNS и т.п.), запускать основное Activity, если оно не запущено — легальный авто-запуск.
**ЦЕЛЬ (уточнено 2026-09-27):** Accessibility должен быть **включён ВСЕГДА**. В него планируется
перенести работу с **WiFi и Bluetooth** (наши каналы, не Mercury). Так гарантируем, что сервис всегда
запущен (система его сама держит/перезапускает, без foreground-нотификации).
**Уже сделано (ядро, проверено на девайсе 2026-09-27):**
- `app-glasses-vm/.../VmAccessibilityService.kt` — пассивный слушатель: НЕ читает содержимое
(`canRetrieveWindowContent=false`), НЕ инжектит. В `onServiceConnected` поднимает `MainActivity`
(авто-запуск).
- `res/xml/vm_accessibility_service.xml` — конфиг (eventTypes=`typeWindowStateChanged`).
- Манифест: `<service>` с `BIND_ACCESSIBILITY_SERVICE` + meta-data.
- Строки в `res/values/strings.xml`.
- **Проверено:** включение работает (и вручную в UI, и программно от shell);
`Enabled services` + `Bound services` подтверждены; при бинде поднимается процесс и `MainActivity`
(`I/VmAccessibilitySvc: Accessibility-сервис подключён` → `MainActivity запущена из
Accessibility-сервиса`).
**МОДЕЛЬ — КАК У SHIZUKU (согласовано 2026-09-27):** provisioning при **каждом** подключении
телефона к очкам проверяет, включён ли Accessibility, и если нет — включает:
```
settings get secure enabled_accessibility_services
# если нашего сервиса нет:
settings put secure enabled_accessibility_services \
pw.binom.rayneovm.glasses/pw.binom.rayneovm.glasses.VmAccessibilityService
settings put secure accessibility_enabled 1
```
Выполнять с телефона через уже готовый **`AdbController`** (wireless-adb → shell). Проверено: от
shell `settings put` работает.
**ДВА ПУТИ ВКЛЮЧЕНИЯ (делаем оба):**
- **(A) Основной — с телефона:** при коннекте `AccessibilityProvisioner.ensure()` (по аналогии с
`ShizukuProvisioner`) проверяет/включает через adb.
- **(B) Самовосстановление — на очках:** само приложение при старте проверяет свой сервис (локально,
через Shizuku) и включает, если выключен.
**ВАЖНЫЕ ОГРАНИЧЕНИЯ (проверено/выяснено):**
- **`force-stop` снимает accessibility-флаг.** `am force-stop`, обновление APK и т.п. выключают
сервис — поэтому provisioning **перепроверяет при каждом коннекте** (не один раз).
- **Ребут:** надо проверить, переживает ли `enabled_accessibility_services` перезагрузку очков
(обычно secure setting переживает, но проверить на RayNeo). Если нет — задействовать наш
HOME-кандидат (`MainActivity` уже с `HOME` intent-filter) и/или `BOOT_COMPLETED`.
- **`onAccessibilityEvent` — мгновенный**, никаких тяжёлых операций (ANR → система отрубит сервис).
Сетевую логику — в корутины/отдельные потоки.
- **Перенос WiFi/BLE-логики в сервис:** сейчас mDNS + WebSocket server живут в foreground-`VmService`.
План — перенести host-логику в `VmAccessibilityService` (или сделать его хостом), `VmService`
сделать тонким/убрать. Обсудить при реализации.
- **Что Accessibility НЕ даёт:** не заменяет MediaProjection/PROJECT_MEDIA appop для #2. Для ввода
(в перспективе) может дать `onAccessibilityEvent` (фокус окна) для #4.
- **Отдельно — киоск (Kiosk / Lock Task Mode):** требует deviceOwner (`dpm set-device-owner` —
одноразово, без сброса не снять) либо whitelisting. Проработать позже, отдельной задачей.
---
# Общие согласования
- Расширять `:shared` `VmMessage` новыми сообщениями (`SetText`, `SetKeyboardActive`, реальный
`SetAudioMode` и т.п.) — **ОК** (APK всё равно пересобираются).
- **Все «повышения» делаются с телефона через `AdbController` при коннекте** (проверено от shell
2026-09-27):
- Accessibility: `settings put secure enabled_accessibility_services` + `accessibility_enabled 1`;
- MediaProjection без диалога: `cmd appops set pw.binom.rayneovm.glasses PROJECT_MEDIA allow`;
- дефолтный IME: `settings put secure default_input_method <наш IME>` (проверить в #4).
- Порядок работы ночью: **#2 → #1 → #3 → #4 → #5** (по убыванию риска/размера).
- Коммиты/пуш — по завершении блоков; отдельно rayneo-vm и rayneo-mercury.
- **Отмашка на начало работ — ждём от пользователя (пока не дана).**
### Правила ночной сессии (согласовано 2026-09-27)
1. **Упёрся в проблему — не стоим.** Зафиксировать её в `BLOCKERS.md` (корень репо): задача, что
сделано, в чём стена, что проверено, варианты обхода. Затем **перейти к следующей задаче**.
2. **Погасить экран очков в конце.** Когда все задачи закрыты ИЛИ дальнейшее продвижение
невозможно — ОБЯЗАТЕЛЬНО выключить экран очков:
`adb -s A06B4AB933C4103 shell input keyevent 223` (KEYCODE_SLEEP), либо через `ScreenControl`
(Канал 1) / Shizuku `SET_SCREEN`. Проверить по `mWakefulness=Dozing`/`Asleep`.
3. Все три пре-авторизации выданы: ребут очков — можно; менять системные настройки очков
(Accessibility / PROJECT_MEDIA / дефолтный IME) на постоянку — можно; при равнозначных развилках
выбирать самому и документировать.
+61 -6
View File
@@ -50,29 +50,70 @@
android:supportsRtl="true" android:supportsRtl="true"
tools:targetApi="36"> tools:targetApi="36">
<!-- MainActivity — заодно HOME-кандидат: на этих очках BOOT_COMPLETED
ненадёжен, а домашний экран система поднимает всегда.
Дефолтным его не назначаем — это делается отдельно
(`cmd package set-home-activity`), по явной команде. -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|navigation|mcc|mnc"
android:launchMode="singleTask" android:launchMode="singleTask"
android:stateNotNeeded="true"
android:clearTaskOnLaunch="true"
android:theme="@style/Theme.RayNeoVm"> android:theme="@style/Theme.RayNeoVm">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </activity>
<!-- Foreground service: WebSocket server + mDNS --> <!-- Foreground service: WebSocket server + mDNS + захват аудио (задача #2) -->
<service <service
android:name=".VmService" android:name=".VmService"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback" /> android:foregroundServiceType="mediaPlayback|mediaProjection" />
<!-- Shizuku UserService для системных вызовов --> <!-- Пассивный Accessibility-сервис (задача #5): живучесть процесса +
легальный запуск Activity из фона. Ничего не читает и не инжектит.
Активируется вручную: Настройки → Специальные возможности → RayNeo VM. -->
<service <service
android:name=".VmShizukuService" android:name=".VmAccessibilityService"
android:exported="true" android:exported="true"
android:process=":shizuku_vd" /> android:label="@string/app_name"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/vm_accessibility_service" />
</service>
<!-- 4.1: системный IME-заглушка. Нулевой UI; назначается дефолтным через Shizuku
(settings put secure default_input_method). Обратно НЕ возвращаем. -->
<service
android:name=".VmNoopImeService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/vm_noop_ime_method" />
</service>
<!-- VmShizukuService НЕ объявляется здесь как <service> — это Shizuku UserService,
который ServiceStarter создаёт через reflection в отдельном isolated-процессе.
Если объявить — Shizuku-сервер не сможет привести класс к IBinder
(ClassCastException в moe.shizuku.starter.ServiceStarter). -->
<!-- Shizuku v12+ требует ShizukuProvider в манифесте — он сам вытаскивает binder <!-- Shizuku v12+ требует ShizukuProvider в манифесте — он сам вытаскивает binder
через ContentProvider IPC и инициализирует Shizuku-клиент. Без него binder не придёт. через ContentProvider IPC и инициализирует Shizuku-клиент. Без него binder не придёт.
@@ -85,6 +126,20 @@
android:exported="true" android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<!-- Отладочный приёмник (защищён DUMP): тестовый тон для проверки аудио-тракта. -->
<receiver
android:name=".debug.GlassesDebugReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="pw.binom.rayneovm.glasses.DEBUG_TEST_TONE" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_PIPE" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_PHONE" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_STOP" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_STOP_TONE" />
</intent-filter>
</receiver>
<meta-data android:name="com.rayneo.mercury.app" android:value="true" /> <meta-data android:name="com.rayneo.mercury.app" android:value="true" />
</application> </application>
</manifest> </manifest>
@@ -1,9 +1,18 @@
package pw.binom.rayneovm.glasses package pw.binom.rayneovm.glasses
import android.app.Activity
import android.content.Intent
import android.content.IntentFilter
import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -17,21 +26,100 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.input.TempleGestureDetector
import pw.binom.rayneovm.glasses.protocol.VmGlassesController import pw.binom.rayneovm.glasses.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
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L
/** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */
private lateinit var controller: VmGlassesController
/**
* Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство
* `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и
* перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через
* [VmGlassesController.sendTempleGesture].
*/
private val templeGestureDetector by lazy {
TempleGestureDetector { gesture -> controller.sendTempleGesture(gesture) }
}
/** id input-устройства дужки (`cyttsp5_mt`); -1 — не найдено. */
private val templeDeviceId: Int by lazy {
InputDevice.getDeviceIds().firstOrNull { id ->
InputDevice.getDevice(id)?.name?.contains(TEMPLE_DEVICE_HINT) == true
} ?: -1
}
/**
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
*/
private val projectionLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
val mp = getSystemService(MediaProjectionManager::class.java)
.getMediaProjection(result.resultCode, data)
if (mp != null) {
Log.i(TAG, "MediaProjection получен")
AudioBridge.onProjectionGranted(mp)
} else {
Log.w(TAG, "getMediaProjection вернул null")
}
} else {
Log.w(TAG, "MediaProjection не выдан: resultCode=${result.resultCode}")
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// HOME живёт на display 0. Если систему (или гостя на VirtualDisplay)
// угораздит запросить домашний экран внутри чужого дисплея — мы бы
// открылись сами внутри себя (зеркальная рекурсия), поэтому выходим.
val displayId = display?.displayId ?: Display.DEFAULT_DISPLAY
if (displayId != Display.DEFAULT_DISPLAY) {
finish()
return
}
enableEdgeToEdge() enableEdgeToEdge()
// По доке Shizuku (https://github.com/RikkaApps/Shizuku-API): подписываемся на
// listener-ы здесь, после super.onCreate, но ДО setContent. ShizukuProvider
// (rikka.shizuku.ShizukuProvider в манифесте) уже инициализировал binder через
// ContentProvider.onCreate — pingBinder() сразу вернёт true.
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).
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
//
// handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе
// Activity в RESUMED (а после диалога проекции мы как раз возвращаемся в RESUMED).
// Без него один и тот же tick запускал бы запрос повторно.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
AudioBridge.requestTick.collect { tick ->
if (tick > handledTick) {
handledTick = tick
launchProjectionRequest()
}
}
}
}
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) {
@@ -42,18 +130,11 @@ class MainActivity : ComponentActivity() {
.background(Color.Black) .background(Color.Black)
) { ) {
val shizukuState by ShizukuBootstrap.state.collectAsState() val shizukuState by ShizukuBootstrap.state.collectAsState()
val vmState by controller.state.collectAsState()
DriveShizukuState(controller, shizukuState) DriveShizukuState(controller, shizukuState)
GlassesScreen( GlassesScreen(
controller = controller, controller = controller,
onRequestShizukuPermission = { ShizukuBootstrap.refresh() }, onRequestShizukuPermission = { ShizukuBootstrap.refresh() },
) )
// Как только VM ушёл в Running (запущен гость) — сворачиваем свой Activity,
// чтобы SurfaceView гостя оказался поверх и был виден на очках.
// Service продолжает жить в фоне (WebSocket/mDNS не рвутся).
if (vmState is GlassesScreenState.Running) {
(this as? ComponentActivity)?.finish()
}
} }
} }
} }
@@ -62,13 +143,38 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
AudioBridge.activityResumed = true
ShizukuBootstrap.refresh() ShizukuBootstrap.refresh()
} }
/** /**
* По доке Shizuku, мы должны вручную передать результат диалога в Shizuku - * Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки
* иначе он не узнает, что пользователь нажал Allow. * (`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>,
@@ -80,6 +186,24 @@ class MainActivity : ComponentActivity() {
ShizukuBootstrap.onRequestPermissionResult(requestCode, grant) ShizukuBootstrap.onRequestPermissionResult(requestCode, grant)
} }
} }
private fun launchProjectionRequest() {
runCatching {
val mpm = getSystemService(MediaProjectionManager::class.java)
projectionLauncher.launch(mpm.createScreenCaptureIntent())
}.onFailure { Log.w(TAG, "Не удалось запустить запрос MediaProjection: ${it.message}") }
}
companion object {
private const val TAG = "MainActivity"
/**
* Имя input-устройства тач-сенсора дужки RayNeo X3 Pro (проверено через `getevent`:
* все касания дужки приходят на `/dev/input/event2`, устройство `cyttsp5_mt`).
* Второй контроллер `cyttsp6_mt` не генерирует событий.
*/
private const val TEMPLE_DEVICE_HINT = "cyttsp5_mt"
}
} }
@Composable @Composable
@@ -112,6 +112,23 @@ object ShizukuBootstrap {
fun isGranted(): Boolean = _state.value == AllowState.Granted fun isGranted(): Boolean = _state.value == AllowState.Granted
/** Снимок состояния без побочных эффектов (не показывает диалог разрешения). */
data class Snapshot(val running: Boolean, val granted: Boolean)
/**
* Пассивная проверка Shizuku: жив ли сервер и выдан ли нам permission.
*
* Зовётся из сервиса (не из Activity), поэтому не имеет права запрашивать разрешение —
* только читает. Нужна, чтобы телефон узнал, поднимать ли Shizuku через ADB.
*/
fun probe(): Snapshot {
val running = try { Shizuku.pingBinder() } catch (_: Throwable) { false }
val granted = if (!running) false else try {
Shizuku.checkSelfPermission() == 0 /* PERMISSION_GRANTED */
} catch (_: Throwable) { false }
return Snapshot(running = running, granted = granted)
}
/** /**
* Хук для Activity.onRequestPermissionResult: перенаправить в Shizuku. * Хук для Activity.onRequestPermissionResult: перенаправить в Shizuku.
* *
@@ -0,0 +1,134 @@
package pw.binom.rayneovm.glasses
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/**
* Accessibility-сервис GlassesApp.
*
* Зачем:
* - живучесть процесса: система сама поднимает и перезапускает сервис;
* - «легальный» запуск Activity из фона;
* - ввод текста в поле гостя на VirtualDisplay ([setText], ACTION_SET_TEXT) — IME на
* этих очках сменить нельзя (ROM жёстко держит LatinIME), поэтому системная
* клавиатура гостя отключается, а текст приходит из телефона сюда.
*
* Регистрируется в манифесте с конфигом `res/xml/vm_accessibility_service.xml`.
* Активируется пользователем вручную: Настройки → Специальные возможности → RayNeo VM.
*/
class VmAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.i(TAG, "Accessibility-сервис подключён")
// Авто-старт: если основное приложение ещё не поднято — поднимаем его.
// Это и есть «легальный авто-запуск» из задачи #5.
runCatching { startActivity(Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}) }
.onSuccess { Log.i(TAG, "MainActivity запущена из Accessibility-сервиса") }
.onFailure { Log.w(TAG, "Не удалось запустить MainActivity: ${it.message}") }
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Намеренно пусто: слушаем только чтобы сервис считался живым.
}
override fun onInterrupt() {
// Ничего: сервис не выполняет длительных операций.
}
override fun onUnbind(intent: Intent?): Boolean {
if (instance === this) instance = null
return super.onUnbind(intent)
}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
/** Заменяет текст в сфокусированном поле ввода гостя. false — если поля в фокусе нет. */
private fun applyText(text: String): Boolean {
val node = findFocusedInputNode() ?: return false
return try {
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
val ok = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
if (!ok) Log.w(TAG, "ACTION_SET_TEXT вернул false")
ok
} catch (t: Throwable) {
Log.w(TAG, "ACTION_SET_TEXT упал", t)
false
}
}
/**
* Ищет сфокусированный узел ввода: сначала в активном окне, затем во всех
* интерактивных окнах (в т.ч. окно гостя на VirtualDisplay).
*/
private fun findFocusedInputNode(): AccessibilityNodeInfo? {
val active = rootInActiveWindow
val activeFocus = active?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
"findFocused: activeRoot=${active?.packageName} focus=${activeFocus?.className} " +
"editable=${activeFocus?.isEditable}",
)
if (activeFocus?.isEditable == true) return activeFocus
findEditable(active)?.let {
Log.i(TAG, "findFocused: редактируемый узел в активном окне: ${it.className}")
return it
}
val ws = windows
Log.i(TAG, "findFocused: окон=${ws?.size ?: 0}")
ws?.forEach { w ->
val root = w.root ?: return@forEach
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
" окно type=${w.type} pkg=${root.packageName} focus=${focus?.className} editable=${focus?.isEditable}",
)
if (focus?.isEditable == true) return focus
findEditable(root)?.let {
Log.i(TAG, " редактируемый узел: ${it.className} pkg=${root.packageName}")
return it
}
}
return null
}
/** Первый редактируемый (EditText-подобный) потомок в дереве. */
private fun findEditable(root: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
root ?: return null
if (root.isEditable) return root
for (i in 0 until root.childCount) {
findEditable(root.getChild(i))?.let { return it }
}
return null
}
companion object {
private const val TAG = "VmAccessibilitySvc"
/** Активный экземпляр сервиса (в том же процессе, что и [VmService]). */
@Volatile
private var instance: VmAccessibilityService? = null
/** Ввести [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun setText(text: String): Boolean {
if (text.isEmpty()) return true
return instance?.applyText(text) ?: false
}
/** Действие для запроса состояния сервиса/проверки активности (диагностика). */
const val ACTION_PING = "pw.binom.rayneovm.glasses.ACCESSIBILITY_PING"
}
}
@@ -0,0 +1,70 @@
package pw.binom.rayneovm.glasses
import android.inputmethodservice.InputMethodService
import android.util.Log
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
/**
* Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay.
*
* Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх
* нашего приложения: [onCreateInputView] возвращает null (нет UI).
*
* Заодно сервис служит каналом ввода текста: только IME держит актуальный [InputConnection]
* сфокусированного поля гостя. Сервис живёт в том же процессе, что и [VmService], поэтому
* текст с телефона ([SetText]) доставляется напрямую через [commit] — без раскладки
* и без кросс-процессного доступа к чужому InputConnection.
*
* Активируется через Shizuku: `settings put secure default_input_method
* pw.binom.rayneovm.glasses/.VmNoopImeService`. Возврат к штатному IME не предусмотрен.
*/
class VmNoopImeService : InputMethodService() {
override fun onCreateInputView(): View? {
Log.i(TAG, "onCreateInputView: возвращаю null UI")
return null
}
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInput(attribute, restarting)
connection = currentInputConnection
Log.i(TAG, "onStartInput: conn=${connection != null} restarting=$restarting")
}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
super.onStartInputView(info, restarting)
connection = currentInputConnection
Log.i(TAG, "onStartInputView: conn=${connection != null}")
}
override fun onFinishInput() {
connection = null
super.onFinishInput()
}
companion object {
private const val TAG = "VmNoopIme"
/**
* Активный [InputConnection] гостя. Наш IME — дефолтный на очках и живёт в том же
* процессе, что и контроллер, поэтому текст инжектится напрямую через этот объект.
*/
@Volatile
private var connection: InputConnection? = null
/** Доставляет [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun commit(text: String): Boolean {
if (text.isEmpty()) return true
val c = connection ?: return false
return try {
c.commitText(text, 1)
true
} catch (t: Throwable) {
Log.w(TAG, "commitText failed", t)
false
}
}
}
}
@@ -4,34 +4,56 @@ 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.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
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.mercury.transport.ServerTransport import pw.binom.mercury.transport.ServerTransport
import pw.binom.mercury.transport.Transports import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.audio.GlassesAudioCapture
import pw.binom.rayneovm.glasses.protocol.VmGlassesController import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.vm.GlassesScreenState import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.AudioMode
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.LaunchAppCommand import pw.binom.rayneovm.shared.LaunchAppCommand
import pw.binom.rayneovm.shared.Ping import pw.binom.rayneovm.shared.Ping
import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.SetText
import pw.binom.rayneovm.shared.Shutdown import pw.binom.rayneovm.shared.Shutdown
import pw.binom.rayneovm.shared.TouchEvent import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.VmMessage import pw.binom.rayneovm.shared.VmMessage
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.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioEncoder
/** /**
* Foreground service на стороне очков. * Foreground service на стороне очков.
@@ -52,14 +74,69 @@ class VmService : LifecycleService() {
private var nsdRegistered: Boolean = false private var nsdRegistered: Boolean = false
private lateinit var controller: VmGlassesController private lateinit var controller: VmGlassesController
// --- Аудио-мост (задача #2): захват системного звука очков и стрим на телефон ---
private var audioServer: ServerTransport<ByteArray>? = null
private val audioSessions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
@Volatile
private var audioCapture: GlassesAudioCapture? = null
private var audioEnsureJob: kotlinx.coroutines.Job? = null
/** Opus-кодер: используется только из аудио-пампа, поэтому не нужна синхронизация. */
private val audioEncoder = OpusAudioEncoder()
/** seq аудио-датаграмм; оборачивается через переполнение Int — это нормально. */
private var audioSeq = 0
@Volatile
private var audioMode: AudioMode = AudioMode.ON_GLASSES
/**
* Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
* [AudioRecord] и Opus-кодером.
*
* Ёмкость — запас на джиттер планировщика/GC/кодера; в норме очередь почти пуста, так что
* на задержку это не влияет. DROP_OLDEST остаётся аварийным клапаном, но при ёмкости 12
* (120 мс) срабатывает куда реже, чем при 4 (40 мс), где любой чих кодировщика рождал
* слышимый «провал» (кадр выкидывался до присвоения seq — телефон этого даже не замечал).
*/
private val audioFrames = Channel<ShortArray>(
capacity = 12,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Диагностика потерь на очках: сколько кадров сняли с AudioRecord и сколько закодировали. */
private val audioCaptured = java.util.concurrent.atomic.AtomicInteger()
private val audioEncoded = java.util.concurrent.atomic.AtomicInteger()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
controller = VmGlassesController.fromContext(this) controller = VmGlassesController.fromContext(this)
Log.i(TAG, "VmService onCreate") Log.i(TAG, "VmService onCreate")
// Стража поднимаем сразу при старте сервиса, а не в момент запуска гостя: убийца
// (BackgroundAppManager) успевает убить за ~2 c после смены top_app, а запуск гостя
// сам эту смену и вызывает. Подробности — в VmGlassesController.ensureMercuryKillGuard.
controller.ensureMercuryKillGuard()
startForeground() startForeground()
startServer() startServer()
startAudioServer()
startAudioPump()
registerMdns() registerMdns()
registerScreenReceiver()
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
// или нам выдали permission) — чтобы телефон не опрашивал вручную.
lifecycleScope.launch {
ShizukuBootstrap.state.collect { pushShizukuStatus() }
}
// MediaProjection приходит из MainActivity асинхронно. Как только он есть,
// а режим — ON_PHONE, сразу пробуем поднять захват (не ждём тик ретрая).
lifecycleScope.launch {
AudioBridge.projection.collect { projection ->
if (projection != null && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
startAudioCapture(projection)
}
}
}
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -76,12 +153,40 @@ class VmService : LifecycleService() {
val channelId = createNotificationChannel() val channelId = createNotificationChannel()
val notification = buildNotification(channelId) val notification = buildNotification(channelId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) // MEDIA_PROJECTION обязателен: без него getMediaProjection() кидает SecurityException
// (Android 12 проверяет тип foreground-сервиса в момент выдачи проекции).
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
} else { } else {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} }
} }
/**
* Текущая активная сессия телефона.
*
* Нужна, чтобы отключение «чужой»/устаревшей сессии (например, пробного WebSocket-хендшейка
* из subnet-фоллбека при поиске очков) не сбрасывало экран в NoConnection, пока жива настоящая.
*/
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) {
@@ -91,11 +196,27 @@ 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)
// Переподключение: снять залипшее «Нет связи с телефоном».
controller.onPhoneConnected()
// И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа).
lifecycleScope.launch { sendShizukuStatus(connId) }
// ...и реальное состояние экрана (для кнопки-тумблера на телефоне).
lifecycleScope.launch { sendScreenState(connId) }
}, },
onDisconnected = { connId -> onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId") liveConns.remove(connId)
Log.i(TAG, "Session closed: $connId (осталось: ${liveConns.size})")
// «Push»-сессию переводим на любую живую, если ушла именно она.
if (currentConn.get() == connId) {
currentConn.set(liveConns.firstOrNull())
}
// Экран гасим ТОЛЬКО когда не осталось ни одной сессии.
if (liveConns.isEmpty()) {
controller.setState(GlassesScreenState.NoConnection) controller.setState(GlassesScreenState.NoConnection)
}
}, },
) )
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -119,12 +240,55 @@ class VmService : LifecycleService() {
controller.launchGuest(msg.packageName) controller.launchGuest(msg.packageName)
} }
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}") is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}")
is VmKeyEvent -> Log.d(TAG, "Key: ${msg.keyCode}") is CursorMove -> {
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}") Log.d(TAG, "CursorMove: ${msg.x},${msg.y}")
controller.moveCursor(msg.x, msg.y)
}
is CursorClick -> {
Log.d(TAG, "CursorClick")
controller.clickCursor()
}
is CursorScroll -> {
Log.d(TAG, "CursorScroll: ${msg.dx},${msg.dy}")
controller.scrollCursor(msg.dx, msg.dy)
}
is VmKeyEvent -> {
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
controller.sendKey(msg.keyCode, msg.action)
}
is SetAudioMode -> {
Log.i(TAG, "SetAudioMode: ${msg.mode}")
applyAudioMode(msg.mode)
}
is ScreenControl -> {
val wantOn = !msg.sleep
val isOn = currentScreenOn()
Log.i(TAG, "ScreenControl: sleep=${msg.sleep} (сейчас on=$isOn)")
if (isOn == wantOn) {
// POWER — переключатель; если цель уже достигнута, инжектить нельзя (моргнём).
lifecycleScope.launch { pushScreenState() }
} else {
controller.setScreen(msg.sleep)
// Экран переключается чуть позже инжекта — сообщаем телефону актуальное состояние.
lifecycleScope.launch {
kotlinx.coroutines.delay(600)
pushScreenState()
}
}
}
is SetText -> {
Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}")
val ok = controller.injectText(msg.displayId, msg.text)
Log.i(TAG, "SetText -> $ok")
}
is Shutdown -> { is Shutdown -> {
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()
@@ -137,11 +301,265 @@ class VmService : LifecycleService() {
Log.e(TAG, "Failed to send app list", t) Log.e(TAG, "Failed to send app list", t)
} }
} }
is RequestShizukuStatus -> sendShizukuStatus(connId)
is Ping -> server?.send(connId, VmProtocol.encode(Pong)) is Ping -> server?.send(connId, VmProtocol.encode(Pong))
else -> Log.d(TAG, "Unhandled: $msg") else -> Log.d(TAG, "Unhandled: $msg")
} }
} }
// ---------------- Аудио-мост (задача #2) ----------------
/**
* UDP-сервер аудио. Телефон шлёт сюда keepalive/подписку, мы запоминаем адрес
* источника (`ip:port`) как пира и отвечаем Opus-датаграммами.
*
* IP телефона НЕ константа — он берётся из адреса входящей датаграммы. В реальной жизни
* очки подключены к точке доступа самого телефона, адреса меняются от сессии к сессии.
*/
private fun startAudioServer() {
audioServer = Transports.udpServer(port = AudioConfig.UDP_PORT)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
audioServer!!.accept(
onMessage = { _, _ -> /* keepalive/подписка: содержимое не важно, важен источник */ },
onConnected = { connId ->
audioSessions.add(connId)
Log.i(TAG, "Audio peer connected: $connId")
},
onDisconnected = { connId ->
audioSessions.remove(connId)
Log.i(TAG, "Audio peer disconnected: $connId")
},
)
} catch (t: Throwable) {
Log.e(TAG, "Audio server crashed", t)
}
}
}
/** Кодирует кадры PCM в Opus и рассылает датаграммы всем подключённым аудио-пирам. */
private fun startAudioPump() {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val packet = ByteArray(AudioConfig.MAX_DATAGRAM)
var encNanos = 0L
var sndNanos = 0L
for (pcm in audioFrames) {
val server = audioServer ?: continue
if (audioSessions.isEmpty()) continue
val t0 = System.nanoTime()
val payload = try {
audioEncoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
} catch (t: Throwable) {
Log.w(TAG, "opus encode failed: ${t.message}")
continue
}
val t1 = System.nanoTime()
val len = AudioDatagram.pack(audioSeq++, payload, payload.size, packet)
val datagram = packet.copyOf(len)
for (connId in audioSessions) {
try {
server.send(connId, datagram)
} catch (t: Throwable) {
Log.w(TAG, "audio send failed: ${t.message}")
}
}
val t2 = System.nanoTime()
encNanos += t1 - t0
sndNanos += t2 - t1
val encoded = audioEncoded.incrementAndGet()
if (encoded % 200 == 0) {
val captured = audioCaptured.get()
Log.i(
TAG,
"audio: captured=$captured encoded=$encoded dropped=${captured - encoded} " +
"sessions=${audioSessions.size} enc_us=${encNanos / 200 / 1000} snd_us=${sndNanos / 200 / 1000}",
)
encNanos = 0
sndNanos = 0
}
}
}
}
/** Применяет аудио-режим, пришедший с телефона. */
private fun applyAudioMode(mode: AudioMode) {
audioMode = mode
when (mode) {
AudioMode.ON_PHONE -> {
// Глушим динамик очков: звук уезжает на телефон, дубля быть не должно.
setOutputMuted(true)
ensureAudioCaptureLoop()
}
AudioMode.ON_GLASSES -> {
audioEnsureJob?.cancel()
audioEnsureJob = null
stopAudioCapture()
setOutputMuted(false)
}
}
}
/**
* Пока режим ON_PHONE и захват не поднят — доводим его до старта.
*
* Проекцию запрашиваем [requestProjection] **один раз** (если её ещё нет). Повторный запрос —
* только если проекция так и не появилась за [AUDIO_RETRY_MS]. Каждый запрос поднимает
* системную grant-активити, а её нельзя запускать, пока наша Activity только-только
* перезапускается — система такие запуски отменяет (`resultCode=0`). Поэтому прежний цикл с
* ре-запросом каждые 2.5 c сам себя и ломал.
*
* Полученную проекцию НЕ гасим ([GlassesAudioCapture.stop] не трогает проекцию): она живёт
* до конца процесса и переиспользуется на все последующие ON_PHONE.
*/
private fun ensureAudioCaptureLoop() {
audioEnsureJob?.cancel()
audioEnsureJob = lifecycleScope.launch {
if (AudioBridge.projection.value == null) requestProjection()
var waited = 0L
while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
val projection = AudioBridge.projection.value
if (projection != null && startAudioCapture(projection)) {
Log.i(TAG, "аудио-захват поднят")
break
}
kotlinx.coroutines.delay(AUDIO_ENSURE_MS)
waited += AUDIO_ENSURE_MS
if (projection == null && waited >= AUDIO_RETRY_MS) {
Log.w(TAG, "проекции всё ещё нет — повторный запрос")
requestProjection()
waited = 0L
}
}
}
}
private fun startAudioCapture(projection: android.media.projection.MediaProjection): Boolean {
if (audioCapture != null) return true
val capture = GlassesAudioCapture(
projection = projection,
sink = { bytes ->
audioCaptured.incrementAndGet()
audioFrames.trySend(bytes)
},
onStopped = { lifecycleScope.launch { onCaptureDied() } },
)
return if (capture.start(lifecycleScope)) {
audioCapture = capture
true
} else {
false
}
}
/** Захват умер сам (read-ошибка) — если режим ещё ON_PHONE, поднимаем заново. */
private fun onCaptureDied() {
if (audioCapture == null) return
audioCapture = null
if (audioMode == AudioMode.ON_PHONE) {
Log.w(TAG, "аудио-захват умер — перезапускаю")
ensureAudioCaptureLoop()
}
}
private fun stopAudioCapture() {
audioCapture?.stop()
audioCapture = null
}
private fun requestProjection() {
AudioBridge.requestProjection()
// MainActivity обычно уже жива и в RESUMED — тогда она сама обработает tick, и трогать
// её нельзя: `startActivity` в этот момент гоняется с запуском consent-активити и
// приводит к отмене grant (resultCode=0). Поднимаем Activity только если она не наверху.
if (!AudioBridge.activityResumed) {
runCatching {
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}") }
}
}
private fun setOutputMuted(muted: Boolean) {
runCatching {
val am = getSystemService(AUDIO_SERVICE) as AudioManager
val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE
am.adjustStreamVolume(AudioManager.STREAM_MUSIC, dir, 0)
Log.i(TAG, "STREAM_MUSIC: ${if (muted) "mute" else "unmute"}")
}.onFailure { Log.w(TAG, "adjustStreamVolume failed: ${it.message}") }
}
/** Отправляет текущее состояние Shizuku конкретной сессии (ответ на запрос). */
private suspend fun sendShizukuStatus(connId: String) {
val snapshot = ShizukuBootstrap.probe()
val json = VmProtocol.encode(
ShizukuStatus(running = snapshot.running, granted = snapshot.granted),
)
try {
server?.send(connId, json)
} catch (t: Throwable) {
Log.w(TAG, "Не удалось отправить ShizukuStatus: ${t.message}")
}
Log.i(TAG, "ShizukuStatus: running=${snapshot.running} granted=${snapshot.granted}")
}
/** Отправляет состояние Shizuku текущей активной сессии (если есть). */
private suspend fun pushShizukuStatus() {
val connId = currentConn.get() ?: return
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) {
@@ -168,7 +586,19 @@ class VmService : LifecycleService() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// Прибираем за собой: гость (force-stop + дисплей), страж (флаг + дисплей + UserService).
// Не покрывает SIGKILL/reinstall — там наш код не выполняется вообще.
runCatching { controller.stopGuest() }
runCatching { controller.stopMercuryKillGuard() }
runCatching { audioEnsureJob?.cancel() }
runCatching { stopAudioCapture() }
runCatching { setOutputMuted(false) }
runCatching { audioFrames.close() }
runCatching { audioServer?.close() }
audioServer = null
audioSessions.clear()
unregisterMdns() unregisterMdns()
unregisterScreenReceiver()
server?.close() server?.close()
server = null server = null
instance = null instance = null
@@ -253,6 +683,13 @@ class VmService : LifecycleService() {
companion object { companion object {
const val WS_PORT = 8080 const val WS_PORT = 8080
const val WS_PATH = "/vm" const val WS_PATH = "/vm"
/** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
private const val AUDIO_ENSURE_MS = 2_500L
/** Через сколько без проекции повторить её запрос (не чаще — иначе гонка grant). */
private const val AUDIO_RETRY_MS = 20_000L
private const val NOTIFICATION_ID = 100 private const val NOTIFICATION_ID = 100
private const val TAG = "VmService" private const val TAG = "VmService"
@@ -273,5 +710,49 @@ class VmService : LifecycleService() {
} }
fun get(): VmService? = instance fun get(): VmService? = instance
/**
* Debug-only: запустить [GlassesAudioCapture] на [seconds] секунд с пустым sink
* (только логирование RMS в logcat). Доказывает, что MediaProjection + AudioPlaybackCapture
* реально поднимаются, без зависимости от WebSocket (который не работает вне общей L3-сети).
*/
fun requestAudioPreview(seconds: Int) {
instance?.let { svc ->
Log.i(TAG, "DEBUG requestAudioPreview ${seconds}s")
AudioBridge.requestProjection()
// Дальше запустим capture когда projection появится. Запускаем polling через lifecycleScope.
svc.lifecycleScope.launch {
val deadline = System.currentTimeMillis() + (seconds * 1000L + 8000L)
while (System.currentTimeMillis() < deadline) {
val p = AudioBridge.projection.value
if (p != null) {
val cap = GlassesAudioCapture(
projection = p,
sink = { /* no-op: RMS пишется внутри capture-цикла */ },
)
if (cap.start(svc.lifecycleScope)) {
Log.i(TAG, "DEBUG preview capture started")
kotlinx.coroutines.delay(seconds * 1000L)
cap.stop()
Log.i(TAG, "DEBUG preview capture stopped")
} else {
Log.w(TAG, "DEBUG preview capture failed to start")
}
break
}
kotlinx.coroutines.delay(500L)
}
}
} ?: Log.w(TAG, "requestAudioPreview: instance is null")
}
/**
* Debug-only: выставить аудио-режим, как если бы пришёл `SetAudioMode`
* по Channel 1 (нужно для тестов, когда WebSocket-канал недоступен).
*/
fun debugSetAudioMode(mode: AudioMode) {
instance?.applyAudioMode(mode)
?: Log.w(TAG, "debugSetAudioMode: instance is null")
}
} }
} }
@@ -3,12 +3,16 @@ package pw.binom.rayneovm.glasses
import pw.binom.mercury.shizuku.ShizukuVdService import pw.binom.mercury.shizuku.ShizukuVdService
/** /**
* Shizuku UserService для системных вызовов (VirtualDisplay, launchActivity, injectInputEvent). * Shizuku UserService для системных вызовов на очках (VirtualDisplay, launchActivity,
* injectInputEvent и т.д.).
* *
* Запускается в отдельном процессе :shizuku_vd через [pw.binom.mercury.shizuku.ShizukuVdClient]. * Запускается в отдельном isolated-процессе от имени shell UID 2000 через
* [pw.binom.mercury.shizuku.ShizukuVdClient].
* *
* Методы Binder-API ([createDisplay], [launchActivity], и т.д.) сейчас — заглушки (STUB): * **Не объявляется в AndroidManifest.xml** как `<service>` — Shizuku создаёт экземпляр
* реальная реализация через [rikka.shizuku.SystemServiceHelper] + reflection добавляется * через `Class.forName().newInstance()`, никакого Android Service-lifecycle не происходит.
* в следующей итерации. *
* Вся логика транзакций — в базовом классе [ShizukuVdService]. Наследник нужен только
* как конкретный класс для `ComponentName` + публичный no-arg конструктор.
*/ */
class VmShizukuService : ShizukuVdService() class VmShizukuService : ShizukuVdService()
@@ -0,0 +1,65 @@
package pw.binom.rayneovm.glasses.audio
import android.media.projection.MediaProjection
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Мост между [android.media.projection.MediaProjection] (получается только из Activity) и
* компонентами, которым нужен захват звука ([VmService]).
*
* MediaProjection API требует, чтобы `createScreenCaptureIntent()` запускался из Activity
* и результат обрабатывался в ней же. Поэтому MainActivity держит [requestTick]-подписку
* и по запросу поднимает системный интент; полученный projection кладётся сюда, а
* [VmService] его забирает.
*
* Важно (задача #2): проекция **одна на процесс и не гасится**. Повторный grant на этом ROM
* ненадёжен — системная grant-активити отменяется, если её запускает только что перезапущенная
* Activity. Поэтому полученную проекцию мы держим живой и переиспользуем для всех последующих
* `ON_PHONE` (останавливаем только [android.media.AudioRecord], не проекцию).
*
* Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается —
* интент сразу возвращает RESULT_OK.
*/
object AudioBridge {
private const val TAG = "AudioBridge"
private val _requestTick = MutableStateFlow(0L)
/** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */
val requestTick: StateFlow<Long> = _requestTick.asStateFlow()
private val _projection = MutableStateFlow<MediaProjection?>(null)
/** Текущий выданный projection (null — ещё не получен). Живёт до конца процесса. */
val projection: StateFlow<MediaProjection?> = _projection.asStateFlow()
/**
* `true`, пока MainActivity в RESUMED. Нужен [VmService], чтобы не дёргать
* `startActivity(MainActivity)` (это ломает grant — гонка с запуском consent-активити),
* когда Activity уже поднята и сама обработает [requestTick].
*/
@Volatile
var activityResumed: Boolean = false
/** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */
fun requestProjection() {
_requestTick.value = _requestTick.value + 1
}
/**
* Принять выданную проекцию. Если проекция уже есть — новую игнорируем и НЕ гасим старую:
* на приложение допускается одна проекция, а её переиспользование — основа стратегии #2.
*/
fun onProjectionGranted(projection: MediaProjection) {
if (_projection.value != null) {
Log.w(TAG, "проекция уже есть — новую игнорирую (старую не трогаю)")
return
}
_projection.value = projection
Log.i(TAG, "проекция сохранена (живёт до конца процесса)")
}
}
@@ -1,159 +0,0 @@
package pw.binom.rayneovm.glasses.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.projection.MediaProjection
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import pw.binom.mercury.transport.ByteTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.glasses.MainActivity
import pw.binom.rayneovm.glasses.VmService
import pw.binom.rayneovm.shared.AudioMode
/**
* Foreground service: захват системного аудио и стрим на телефон.
*
* Два режима:
* - AudioPlaybackCapture (Android 12+): захват звука других приложений.
* Требует MediaProjection (получается через ActivityResult API).
* - MIC fallback (Android < 12 или без MediaProjection): обычный микрофон.
*
* Сейчас сервис стартует в MIC-режиме (для каркаса), реальный AudioPlaybackCapture
* требует Activity-инициацию через onActivityResult — TODO.
*/
class AudioCaptureService : android.app.Service() {
private var recorder: AudioRecord? = null
private var streamer: AudioStreamer? = null
private var transport: ByteTransport? = null
override fun onBind(intent: Intent): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val modeExtra = intent?.getStringExtra(EXTRA_MODE)
if (modeExtra != AudioMode.ON_PHONE.name) {
Log.i(TAG, "Stop: mode=$modeExtra")
stopCapture()
stopSelf()
return START_NOT_STICKY
}
startForegroundCompat()
startCapture()
return START_STICKY
}
private fun startForegroundCompat() {
val channelId = createChannel()
val notification = buildNotification(channelId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun startCapture() {
val sampleRate = SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBuffer <= 0) {
Log.w(TAG, "getMinBufferSize returned $minBuffer")
return
}
val bufferSize = minBuffer * 4
// MIC fallback. Реальный AudioPlaybackCaptureConfiguration требует MediaProjection,
// который получается через Activity.onActivityResult — TODO.
@Suppress("MissingPermission")
recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate, channelMask, encoding, bufferSize
)
val wsUrl = "ws://127.0.0.1:${VmService.WS_PORT}/vm-audio"
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
streamer = AudioStreamer(recorder!!, transport!!)
recorder!!.startRecording()
kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { streamer!!.run() }
Log.i(TAG, "AudioCapture (MIC fallback) started: $sampleRate Hz, buffer=$bufferSize bytes")
}
private fun stopCapture() {
recorder?.stop()
recorder?.release()
recorder = null
streamer?.stop()
streamer = null
transport?.close()
transport = null
Log.i(TAG, "AudioCapture stopped")
}
override fun onDestroy() {
super.onDestroy()
stopCapture()
}
private fun createChannel(): String {
val id = "vm_audio_capture"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val mgr = getSystemService(NotificationManager::class.java)
mgr.createNotificationChannel(NotificationChannel(id, "Audio Capture", NotificationManager.IMPORTANCE_LOW))
}
return id
}
private fun buildNotification(channelId: String): Notification {
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("RayNeo VM: Audio Capture")
.setContentText("Захват системного аудио")
.setContentIntent(openApp)
.setOngoing(true)
.build()
}
companion object {
private const val TAG = "AudioCaptureService"
private const val NOTIFICATION_ID = 200
private const val EXTRA_MODE = "mode"
const val SAMPLE_RATE = 44100
fun start(context: Context, mode: AudioMode) {
val intent = Intent(context, AudioCaptureService::class.java).apply {
putExtra(EXTRA_MODE, mode.name)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, AudioCaptureService::class.java))
}
}
}
@@ -1,62 +0,0 @@
package pw.binom.rayneovm.glasses.audio
import android.media.AudioRecord
import android.util.Log
import pw.binom.mercury.transport.ByteTransport
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Читает PCM из [AudioRecord] и шлёт сырые байты через [ByteTransport].
*
* Буфер — 4 KiB (примерно 23 мс @ 44.1 кГц 16-bit stereo). Задержка стрима — небольшая.
*
* TODO: добавить Opus-кодирование (30 KB/s вместо 176 KB/s) для BT-режима.
*/
class AudioStreamer(
private val recorder: AudioRecord,
private val transport: ByteTransport,
) {
@Volatile
private var stopped = false
suspend fun run() {
val buf = ByteArray(BUFFER_BYTES)
try {
transport.connect(
onMessage = { /* incoming: ignore */ },
onConnected = { /* ok */ },
onDisconnected = { Log.w(TAG, "transport disconnected"); stopped = true },
)
} catch (t: Throwable) {
Log.e(TAG, "transport connect failed", t)
return
}
withContext(Dispatchers.IO) {
while (!stopped) {
val read = recorder.read(buf, 0, buf.size)
if (read > 0) {
try {
transport.send(buf.copyOf(read))
} catch (t: Throwable) {
Log.w(TAG, "send failed: ${t.message}")
break
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read error: $read")
break
}
}
}
}
fun stop() {
stopped = true
}
companion object {
private const val TAG = "AudioStreamer"
private const val BUFFER_BYTES = 4096
}
}
@@ -0,0 +1,152 @@
package pw.binom.rayneovm.glasses.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.projection.MediaProjection
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.rayneovm.shared.audio.AudioConfig
/**
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
*
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
*
* Пишет 48 кГц stereo 16-bit кадрами ровно по [AudioConfig.FRAME_MS] мс и отдаёт их
* в [sink] на `Dispatchers.IO` как `ShortArray` (interleaved). Кадр фиксированного
* размера нужен Opus-кодеру: он кодирует ровно [AudioConfig.FRAME_SAMPLES] сэмплов/канал.
*
* Раз в ~1 c пишется RMS в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
*
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
*/
class GlassesAudioCapture(
private val projection: MediaProjection,
private val sink: suspend (ShortArray) -> Unit,
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
private val onStopped: (() -> Unit)? = null,
) {
private var record: AudioRecord? = null
private var job: Job? = null
@Volatile private var running = false
fun start(scope: CoroutineScope): Boolean {
if (running) return true
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBuf <= 0) {
Log.w(TAG, "getMinBufferSize=$minBuf")
return false
}
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше четырёх кадров
// (40 мс): при двух кадрах любой сбой планировщика/GC приводил к overrun и «просадке».
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 4)
val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.build()
AudioRecord.Builder()
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(encoding)
.setChannelMask(channelMask)
.build(),
)
.setBufferSizeInBytes(bufferSize)
.setAudioPlaybackCaptureConfig(config)
.build()
} catch (t: Throwable) {
Log.e(TAG, "AudioRecord.Builder упал: ${t.message}", t)
return false
}
if (rec.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord не инициализирован (state=${rec.state}); вероятно нет PROJECT_MEDIA")
rec.release()
return false
}
record = rec
running = true
rec.startRecording()
job = scope.launch(Dispatchers.IO) {
val frame = ShortArray(AudioConfig.FRAME_SHORTS)
var filled = 0
var frameCount = 0
var totalSamples = 0L
while (isActive && running) {
val read = rec.read(frame, filled, frame.size - filled)
if (read > 0) {
filled += read
if (filled == frame.size) {
try {
sink(frame.copyOf())
} catch (t: Throwable) {
Log.w(TAG, "sink упал: ${t.message}")
}
totalSamples += filled
frameCount++
if (frameCount % 100 == 0) {
Log.i(TAG, "capture: samples=$totalSamples rms=${rms16(frame).toInt()}")
}
filled = 0
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read ошибка: $read")
break
}
}
if (running && isActive) {
running = false
Log.w(TAG, "цикл захвата завершился сам — сообщаю наверх")
onStopped?.invoke()
}
}
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, кадр=${AudioConfig.FRAME_MS}мс, buffer=$bufferSize")
return true
}
/**
* Останавливает только [AudioRecord]. Проекцию НЕ гасит: она одна на процесс и
* переиспользуется (см. [AudioBridge]) — иначе следующий `ON_PHONE` требовал бы нового
* grant, который на этом ROM ненадёжен.
*/
fun stop() {
running = false
job?.cancel()
job = null
record?.let {
runCatching { it.stop() }
runCatching { it.release() }
}
record = null
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
}
private fun rms16(buf: ShortArray): Double {
if (buf.isEmpty()) return 0.0
var sumSq = 0.0
for (s in buf) {
sumSq += s.toDouble() * s
}
return Math.sqrt(sumSq / buf.size)
}
companion object {
private const val TAG = "GlassesAudioCapture"
}
}
@@ -0,0 +1,130 @@
package pw.binom.rayneovm.glasses.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.VmService
import pw.binom.rayneovm.shared.AudioMode
/**
* Отладочные команды для очков. Защищены разрешением `android.permission.DUMP`.
*
* Использование:
* ```
* adb -s A06B4AB933C4103 shell am broadcast \
* -a pw.binom.rayneovm.glasses.DEBUG_TEST_TONE \
* --ei seconds 10 \
* -n pw.binom.rayneovm.glasses/.debug.GlassesDebugReceiver
* ```
*
* Тестовый тон пишет 440 Гц PCM 16-bit стерео через AudioTrack.USAGE_MEDIA → гарантированно
* попадает в AudioFlinger и проверяет:
* - динамик очков работает (до mute в #2);
* - аудио-захват AudioPlaybackCapture на очках действительно ловит поток (через log RMS).
*/
class GlassesDebugReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_TEST_TONE -> TestTone.play(intent.getIntExtra("seconds", 10))
ACTION_STOP_TONE -> TestTone.stop()
ACTION_AUDIO_PIPE ->
VmService.requestAudioPreview(intent.getIntExtra("seconds", 8))
ACTION_STOP_PHONE_AUDIO -> VmService.debugSetAudioMode(AudioMode.ON_GLASSES)
ACTION_START_PHONE_AUDIO -> VmService.debugSetAudioMode(AudioMode.ON_PHONE)
}
}
companion object {
const val ACTION_TEST_TONE = "pw.binom.rayneovm.glasses.DEBUG_TEST_TONE"
const val ACTION_STOP_TONE = "pw.binom.rayneovm.glasses.DEBUG_STOP_TONE"
const val ACTION_AUDIO_PIPE = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_PIPE"
const val ACTION_START_PHONE_AUDIO = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_PHONE"
const val ACTION_STOP_PHONE_AUDIO = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_STOP"
}
}
/** Глобальный односекундный синус 440 Гц PCM16 стерео для самотестирования аудио-тракта очков. */
object TestTone {
private const val TAG = "TestTone"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var job: Job? = null
private val lock = Any()
fun play(seconds: Int) {
synchronized(lock) {
job?.cancel()
job = scope.launch {
val sr = 44100
val stereo = AudioFormat.CHANNEL_OUT_STEREO
val pcm16 = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sr, stereo, pcm16)
if (minBuf <= 0) {
Log.w(TAG, "getMinBufferSize=$minBuf (не запускаем тон)")
return@launch
}
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sr)
.setEncoding(pcm16)
.setChannelMask(stereo)
.build(),
)
.setBufferSizeInBytes(minBuf * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
runCatching { track.play() }.onFailure { Log.e(TAG, "track.play() failed", it) }
Log.i(TAG, "test tone start: $sr Hz stereo, ${seconds}s")
val totalFrames = sr * seconds
val buf = ShortArray(2048)
var phase = 0.0
val step = 2.0 * Math.PI * 440.0 / sr
var written = 0
while (isActive && written < totalFrames) {
for (i in buf.indices step 2) {
val s = (Math.sin(phase) * 8000.0).toInt().toShort()
buf[i] = s
buf[i + 1] = s
phase += step
if (phase > 2 * Math.PI) phase -= 2 * Math.PI
}
track.write(buf, 0, buf.size)
written += buf.size / 2
}
runCatching { track.stop() }
track.release()
Log.i(TAG, "test tone done: written=${written * 2}bytes")
}
}
}
fun stop() {
synchronized(lock) {
job?.cancel()
job = null
Log.i(TAG, "test tone stopped by request")
}
}
fun release() {
stop()
scope.cancel()
}
}
@@ -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
}
}
@@ -5,19 +5,35 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.os.IBinder
import android.os.SystemClock
import android.util.Log import android.util.Log
import android.view.Surface
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
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
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
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.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 kotlin.math.abs
/** /**
* Контроллер состояния экрана очков и кадров. * Контроллер состояния экрана очков и кадров.
@@ -32,22 +48,65 @@ class VmGlassesController {
private val _frameSource = MutableStateFlow<FrameSource?>(null) private val _frameSource = MutableStateFlow<FrameSource?>(null)
val frameSource: StateFlow<FrameSource?> = _frameSource.asStateFlow() val frameSource: StateFlow<FrameSource?> = _frameSource.asStateFlow()
/** Позиция виртуального курсора мыши (координаты VirtualDisplay). null — курсор не показываем. */
private val _cursor = MutableStateFlow<CursorPosition?>(null)
val cursor: StateFlow<CursorPosition?> = _cursor.asStateFlow()
private var activeSource: VirtualDisplayFrameSource? = null private var activeSource: VirtualDisplayFrameSource? = null
/** Пакет гостя, запущенного на [activeSource] — его и убиваем в [stopGuest]. */
@Volatile private var activeGuestPackage: String? = null
/** Служебный дисплей Mercury-«стража» (см. [ensureMercuryKillGuard]); 0 — не создан. */
private var guardDisplayId = 0
/** Surface служебного дисплея: без валидной Surface дисплей считается OFF и активность не стартует. */
private var guardSurfaceTexture: SurfaceTexture? = null
/** Отдельный долгоживущий Shizuku-клиент стража — не привязан к жизни гостевого дисплея. */
private var guardClient: ShizukuVdClient? = null
/** Binder того же клиента (после успешного bind). */
private var guardBinder: IBinder? = null
/** Watchdog: поднимает стража и переподнимает его активность, пока живёт приложение. */
private var guardWatchdog: Job? = null
/** Дельты скролла от телефона: копим и инжектим пачкой (см. [scrollCursor]). */
private val scrollChannel = Channel<FloatArray>(Channel.UNLIMITED)
/** Насос скролла: один на приложение. */
private var scrollPump: Job? = null
/**
* Watchdog автоскрытия курсора: после [CURSOR_HIDE_MS] бездействия курсор гасится. Перезапускается
* на каждом действии тачпада. Курсор появится вновь при следующем move/click/scroll — место
* появления не принципиально (новое — от последнего moveCursor).
*/
private var cursorHideJob: Job? = null
@Volatile private var appContext: Context? = null @Volatile private var appContext: Context? = null
suspend fun acquireFrameSource(packageName: String): FrameSource? { /** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
private val acquireMutex = Mutex()
suspend fun acquireFrameSource(packageName: String): FrameSource? = acquireMutex.withLock {
val ctx = appContext ?: run { val ctx = appContext ?: run {
Log.w(TAG, "acquireFrameSource: appContext not set") Log.w(TAG, "acquireFrameSource: appContext not set")
return null return@withLock null
} }
// Смена приложения: прошлого гостя сначала УБИВАЕМ, и только потом рвём его дисплей —
// иначе задача переезжает на display 0 и вылезает во весь экран (см. [stopGuest]).
killActiveGuest()
releaseFrameSource() releaseFrameSource()
val surfaceTexture = try { val surfaceTexture = try {
SurfaceTexture(true).also { // ВАЖНО: single-buffer нельзя — DisplayManagerService reject'ит
// "Surface can't be single-buffered" при создании VirtualDisplay.
SurfaceTexture(false).also {
it.setDefaultBufferSize(VIRTUAL_WIDTH, VIRTUAL_HEIGHT) it.setDefaultBufferSize(VIRTUAL_WIDTH, VIRTUAL_HEIGHT)
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "SurfaceTexture creation failed", t) Log.e(TAG, "SurfaceTexture creation failed", t)
return null return@withLock null
} }
val source = try { val source = try {
VirtualDisplayFrameSource.acquire( VirtualDisplayFrameSource.acquire(
@@ -60,11 +119,12 @@ class VmGlassesController {
) )
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "VirtualDisplayFrameSource.acquire failed for $packageName", t) Log.e(TAG, "VirtualDisplayFrameSource.acquire failed for $packageName", t)
return null return@withLock null
} }
activeSource = source activeSource = source
activeGuestPackage = packageName
_frameSource.value = source _frameSource.value = source
return source source
} }
/** Освобождает активный VirtualDisplay и очищает FrameSource. */ /** Освобождает активный VirtualDisplay и очищает FrameSource. */
@@ -82,74 +142,538 @@ class VmGlassesController {
_frameSource.value = null _frameSource.value = null
} }
/** Обновляет состояние экрана. */
fun setState(newState: GlassesScreenState) { fun setState(newState: GlassesScreenState) {
Log.i(TAG, "setState: $newState") Log.i(TAG, "setState: $newState")
_state.value = newState _state.value = newState
} }
/** /**
* DEBUG: запустить гостя напрямую (без Compose-UI). * Телефон подключился (в том числе переподключился после разрыва). Если на экране висел
* Используется только для тестирования cross-process Surface на устройстве. * [GlassesScreenState.NoConnection], снимаем его — связь-то восстановлена. Гостя не трогаем:
* если он запущен, возвращаем [GlassesScreenState.Running], иначе [GlassesScreenState.NoAppSelected].
*/
fun onPhoneConnected() {
if (_state.value !is GlassesScreenState.NoConnection) return
val pkg = activeGuestPackage
if (pkg != null) setState(GlassesScreenState.Running(pkg))
else setState(GlassesScreenState.NoAppSelected)
}
/**
* Перемещает виртуальный курсор мыши. Очки рисуют его в обоих глазах, а [clickCursor]
* использует последнюю позицию как точку клика.
*/
fun moveCursor(x: Float, y: Float) {
_cursor.value = CursorPosition(
x = x.coerceIn(0f, VmDisplay.WIDTH - 1f),
y = y.coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
scheduleCursorHide()
}
/**
* Клик мыши в текущей позиции курсора: инжектим гостю пару DOWN+UP ОДНОЙ транзакцией.
*
* Раньше DOWN и UP уходили отдельными сообщениями/корутинами — порядок не гарантирован,
* тач-поток у гостя разбалансировался и клик срабатывал ровно один раз.
*/
fun clickCursor() {
val src = activeSource ?: return
val pos = _cursor.value ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
scheduleCursorHide()
testScope.launch {
val ok = ShizukuVdBinder.tap(binder, displayId, pos.x, pos.y)
if (!ok) Log.w(TAG, "clickCursor: tap failed (displayId=$displayId)")
}
}
/**
* Скролл в текущей позиции курсора: [dx]/[dy] — смещение пальцев в пикселях координат
* гостя (два пальца по тачпаду). Инжектим колесо мыши (`ACTION_SCROLL`) в точку курсора.
*
* Гость умножает axis-значение на `ViewConfiguration.scaledVerticalScrollFactor`
* (≈ [SCROLL_FACTOR]px при density 160), поэтому делим на него: смещение становится
* 1:1 с движением пальца.
*/
/**
* Скролл тачпада: дельты пальца копим и раз в [SCROLL_TICK_MS] отправляем одним
* синтетическим драгом. Инжектить на каждое движение пальца нельзя — жесты перекрываются.
*/
fun scrollCursor(dx: Float, dy: Float) {
if (dx == 0f && dy == 0f) return
scheduleCursorHide()
scrollChannel.trySend(floatArrayOf(dx, dy))
ensureScrollPump()
}
/**
* Перезапускает watchdog автоскрытия курсора. Если гость работает и через [CURSOR_HIDE_MS] не
* будет новых действий с тачпада — прячем курсор.
*/
private fun scheduleCursorHide() {
if (activeSource == null) return
cursorHideJob?.cancel()
cursorHideJob = testScope.launch {
delay(CURSOR_HIDE_MS)
_cursor.value = null
}
}
/** Останавливает watchdog и очищает курсор (вызов из [stopGuest]). */
private fun cancelCursorHide() {
cursorHideJob?.cancel()
cursorHideJob = null
}
/** Один насос на приложение: копит дельты и инжектит пачкой. */
private fun ensureScrollPump() {
if (scrollPump != null) return
scrollPump = testScope.launch {
var accX = 0f
var accY = 0f
var lastDeltaAt = 0L
while (isActive) {
delay(SCROLL_TICK_MS)
var got = false
while (true) {
val r = scrollChannel.tryReceive().getOrNull() ?: break
accX += r[0]
accY += r[1]
got = true
}
val src = activeSource
val pos = _cursor.value
if (src == null || pos == null) {
accX = 0f
accY = 0f
continue
}
val now = SystemClock.uptimeMillis()
if (got) lastDeltaAt = now
// Копили мало и пауза — сбрасываем хвост, иначе он выстрелит в следующем жесте.
if (!got && now - lastDeltaAt > SCROLL_IDLE_RESET_MS) {
accX = 0f
accY = 0f
continue
}
// Драг короче ~90 px гость считает кликом по элементу списка, а не скроллом,
// поэтому короткие пачки продолжаем копить.
if (abs(accX) < SCROLL_MIN_PX && abs(accY) < SCROLL_MIN_PX) continue
val ok = ShizukuVdBinder.scroll(
binder = src.shizukuBinder,
displayId = src.virtualDisplayId,
x = pos.x,
y = pos.y,
dx = accX,
dy = accY,
)
if (!ok) Log.w(TAG, "scrollCursor: scroll failed (displayId=${src.virtualDisplayId})")
accX = 0f
accY = 0f
}
}
}
/**
* Гасит/будит экран самих очков (дисплей 0) через Shizuku-UserService.
*
* Идёт через инжект `KEYCODE_SLEEP`/`KEYCODE_WAKEUP` от shell UID, поэтому не зависит
* ни от Mercury BLE, ни от wireless-adb. Биндер берём у «стража» (он всегда поднят, пока
* живёт приложение), иначе — у активного гостевого дисплея.
*/
fun setScreen(sleep: Boolean) {
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "setScreen(sleep=$sleep): нет Shizuku-биндера (Shizuku не поднят?)")
return
}
testScope.launch {
val ok = runCatching { ShizukuVdBinder.setScreen(binder, !sleep) }
.onFailure { Log.w(TAG, "setScreen: binder мёртв", it) }
.getOrDefault(false)
Log.i(TAG, "setScreen(sleep=$sleep) -> $ok")
}
}
/**
* Инжектит [text] в активное текстовое поле гостя на [displayId] (через InputConnection.commitText).
*
* Если фокуса в текстовом поле нет — возвращает false (тогда на телефоне остаётся
* keyCodeForChar-фолбэк). Биндер берётся у «стража» (он поднят пока живёт VmService), иначе —
* у активного гостевого дисплея.
*/
fun injectText(displayId: Int, text: String): Boolean {
if (text.isEmpty()) return true
// Основной путь: AccessibilityService + ACTION_SET_TEXT — единственный способ
// достучаться до поля гостя на VirtualDisplay без IME (сменить IME на этих очках
// нельзя, а системная клавиатура гостя отключена).
if (VmAccessibilityService.setText(text)) {
Log.i(TAG, "injectText: доставлено через Accessibility (len=${text.length})")
return true
}
// Резерв: если наш IME всё-таки является активным — шлём через его InputConnection.
if (VmNoopImeService.commit(text)) {
Log.i(TAG, "injectText: доставлено через IME (len=${text.length})")
return true
}
Log.w(TAG, "injectText: нет активного поля ввода (ни Accessibility, ни IME)")
return false
}
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
fun sendKey(keyCode: Int, action: Int) {
val src = activeSource ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
testScope.launch {
val ok = ShizukuVdBinder.injectKeyEvent(binder, displayId, keyCode, action)
if (!ok) Log.w(TAG, "sendKey: injectKeyEvent failed (keyCode=$keyCode)")
}
}
/**
* Жест тач-сенсора дужки → событие гостю на VirtualDisplay.
*
* Маппинг — DPAD-клавиши (крестовина джойстика, которую понимает гость, например ZONA):
* - [TempleGesture.Swipe] с доминирующей осью X → LEFT/RIGHT, осью Y → UP/DOWN;
* - [TempleGesture.DoubleClick] → KEYCODE_BACK («назад»);
* - [TempleGesture.Click] → KEYCODE_DPAD_CENTER (подтверждение).
*
* Знаки осей (какой свайп = «вверх») подбираются эмпирически на устройстве.
* Каждому событию инжектим DOWN+UP парой, как одиночное нажатие клавиши.
*/
fun sendTempleGesture(gesture: TempleGesture) {
val src = activeSource ?: run {
Log.d(TAG, "sendTempleGesture($gesture): нет активного гостя")
return
}
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
val keyCode: Int = when (gesture) {
is TempleGesture.Swipe -> {
if (kotlin.math.abs(gesture.dx) >= kotlin.math.abs(gesture.dy)) {
if (gesture.dx >= 0f) android.view.KeyEvent.KEYCODE_DPAD_RIGHT
else android.view.KeyEvent.KEYCODE_DPAD_LEFT
} else {
if (gesture.dy >= 0f) android.view.KeyEvent.KEYCODE_DPAD_DOWN
else android.view.KeyEvent.KEYCODE_DPAD_UP
}
}
is TempleGesture.DoubleClick -> android.view.KeyEvent.KEYCODE_BACK
is TempleGesture.Click -> android.view.KeyEvent.KEYCODE_DPAD_CENTER
}
Log.i(TAG, "sendTempleGesture: $gesture -> keyCode=$keyCode")
testScope.launch {
val down = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_DOWN,
)
val up = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_UP,
)
if (!down || !up) Log.w(TAG, "sendTempleGesture: injectKeyEvent failed (keyCode=$keyCode)")
}
}
/**
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast.
*/ */
fun startTestLaunch(packageName: String) { fun startTestLaunch(packageName: String) {
Log.i(TAG, "startTestLaunch: $packageName") Log.i(TAG, "startTestLaunch: $packageName")
setState(GlassesScreenState.Running(packageName)) launchGuest(packageName)
// Прямо сейчас (без ожидания Compose) запускаем создание FrameSource и activity.
testScope.launch {
try {
val src = acquireFrameSource(packageName)
Log.i(TAG, "acquireFrameSource result: $src for $packageName")
} catch (t: Throwable) {
Log.e(TAG, "startTestLaunch: acquire failed", t)
}
}
} }
/** Запустить гостя через VirtualDisplay (вызывается из VmService по LaunchAppCommand). */ /** Запустить гостя через VirtualDisplay (вызывается из VmService по LaunchAppCommand). */
fun launchGuest(packageName: String) { fun launchGuest(packageName: String) {
Log.i(TAG, "launchGuest: $packageName") Log.i(TAG, "launchGuest: $packageName")
setState(GlassesScreenState.Running(packageName)) setState(GlassesScreenState.Running(packageName))
// Курсор появляется по центру экрана — иначе первый клик уйдёт «в никуда».
_cursor.value = CursorPosition(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)
// Шаг 1: запустить гостя на основном дисплее через обычный startActivity. // Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
// Это работает без системных прав — обычный Android API. // Без Shizuku acquireFrameSource вернёт null → гость не запустится.
// Пока без VirtualDisplay, потом перебросим на него (нужна Shizuku).
val ctx = appContext
if (ctx == null) {
Log.w(TAG, "launchGuest: appContext не установлен")
return
}
try {
val intent = ctx.packageManager.getLaunchIntentForPackage(packageName)
if (intent == null) {
Log.w(TAG, "launchGuest: $packageName не установлен")
return
}
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
ctx.startActivity(intent)
Log.i(TAG, "launchGuest: $packageName запущен на основном дисплее")
} catch (t: Throwable) {
Log.e(TAG, "launchGuest: не удалось запустить $packageName", t)
return
}
// Шаг 2: создать VirtualDisplay (нужны Shizuku права). Если Shizuku не активен —
// FrameSource останется null и GL-слой покажет чёрный экран. Но приложение
// уже запущено на основном дисплее очков, и пользователь его увидит.
testScope.launch { testScope.launch {
val src = acquireFrameSource(packageName) val src = acquireFrameSource(packageName) as? VirtualDisplayFrameSource
if (src == null) { if (src == null) {
Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku?), гость работает на основном дисплее") Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku не активен?)")
} else { setState(GlassesScreenState.Error("Не удалось создать виртуальный экран (Shizuku?)"))
Log.i(TAG, "launchGuest: FrameSource активен для $packageName") return@launch
}
// Шаг 2: запустить гостя на нашем виртуальном дисплее через Shizuku-UserService.
// Запуск идёт от лица shell UID — ActivityOptions.setLaunchDisplayId проходит
// проверку AMS и Activity стартует на нашем virtual display, не на основном.
// Разрешаем MainActivity на клиенте через наш PackageManager — UserService
// работает в isolated-процессе без своего PackageManager.
val ctx2 = appContext
if (ctx2 == null) {
Log.w(TAG, "launchGuest: appContext not set")
return@launch
}
val pm = ctx2.packageManager
val launchIntent = pm.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: ""
// Стража обычно поднимает ensureMercuryKillGuard() при старте сервиса; здесь —
// подстраховка, если он ещё не поднялся (идемпотентно).
ensureMercuryKillGuard()
val ok = ShizukuVdBinder.launchActivity(
binder = src.shizukuBinder,
packageName = packageName,
className = className,
displayId = src.virtualDisplayId,
)
Log.i(TAG, "launchGuest: $packageName/$className запущен на virtualDisplayId=${src.virtualDisplayId}, ok=$ok")
if (!ok) {
setState(GlassesScreenState.Error("Не удалось запустить $packageName"))
} }
} }
} }
/** Остановить гостя и освободить VirtualDisplay (по Shutdown или смене приложения). */ /**
* Остановить гостя и освободить VirtualDisplay (по Shutdown или смене приложения).
*
* Порядок критичен: СНАЧАЛА force-stop гостя, потом release дисплея. Если освободить
* дисплей первым, система не убивает процесс, а переносит задачу на основной дисплей —
* приложение вылезает на очках во весь экран (так было с `free.zona`).
*
* Всё под [acquireMutex]: параллельный запуск нового гостя не должен поднять дисплей,
* пока мы добиваем старого.
*/
fun stopGuest() { fun stopGuest() {
Log.i(TAG, "stopGuest") Log.i(TAG, "stopGuest")
releaseFrameSource() if (activeSource == null && activeGuestPackage == null) {
setState(GlassesScreenState.NoAppSelected) setState(GlassesScreenState.NoAppSelected)
return
}
testScope.launch {
acquireMutex.withLock {
killActiveGuest()
releaseFrameSource()
cancelCursorHide()
_cursor.value = null
setState(GlassesScreenState.NoAppSelected)
}
}
}
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + `kill -9` процессов-сирот вроде scrcpy.
*
* Выполняется нашим Shizuku UserService от shell UID. Защищены: сам GlassesApp, Shizuku
* (через него же и работаем) и активный гость на VirtualDisplay — по просьбе пользователя
* его не трогаем. Дисплей гостя при этом не освобождаем: убьём только процессы вокруг.
*/
fun forceStopAll() {
testScope.launch {
// Страж живёт постоянно (watchdog), его binder — надёжный долгоживущий вариант.
// Если стража нет, берём binder активного гостя.
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "forceStopAll: нет Shizuku binder (стража нет, гостя нет)")
return@launch
}
val protected = buildList {
add(GLASSES_PACKAGE)
add(SHIZUKU_PACKAGE)
activeGuestPackage?.let { add(it) }
}
val killed = runCatching {
ShizukuVdBinder.forceStopAll(
binder = binder,
protectedPackages = protected,
extraPackages = listOf(XR_RUNTIME_PACKAGE),
processNameSubstrings = listOf(SCRCPY_PROCESS),
)
}.getOrElse {
Log.w(TAG, "forceStopAll failed", it)
-1
}
Log.i(TAG, "forceStopAll: killed=$killed (protected=$protected)")
}
}
/**
* Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex].
*
* Без этого процесс гостя остаётся жить (со своим VirtualDisplay он уже не связан), а при
* простом release дисплея система переносит задачу на display 0 — гость вылезает во весь
* экран очков.
*/
private suspend fun killActiveGuest() {
val src = activeSource
val pkg = activeGuestPackage
if (src == null || pkg == null) return
val killed = ShizukuVdBinder.forceStopPackage(src.shizukuBinder, pkg)
Log.i(TAG, "killActiveGuest: forceStop($pkg) -> $killed")
activeGuestPackage = null
// Даём AMS снять задачу, прежде чем рвать дисплей.
if (killed) delay(GUEST_KILL_SETTLE_MS)
}
/**
* Поднимает Mercury-«страж» и держит его, пока живёт приложение.
*
* У RayNeo политика «одно приложение на переднем плане»: на каждой смене `Secure.top_app`
* лаунчер force-стопит все пакеты из накопительной очереди предыдущих топов. Гость на
* VirtualDisplay тоже считается топом, поэтому лаунчер убивал то наше приложение, то гостя
* (снаружи выглядело как «клик работает ровно один раз»).
*
* Легальный выключатель — вендорский `BackgroundAppManager.enableStopBackgroundKill(true)`.
* Его ставит `NetConfigActivity.onStart` и снимает в `onStop`, поэтому держим эту активность
* запущенной на отдельном невидимом VirtualDisplay: её никто не перекрывает, флаг остаётся
* true, и `forceStopApp` отменяется на проверке `tmpStopBackgroundKill`.
*
* Поднимать надо заранее, до первого запуска гостя: у убийцы есть окно ~2 c между сменой
* top_app и проверкой флага, а активность на скрытом дисплее стартует не мгновенно. Пока
* страж не поднят, любая смена топа (в том числе сам запуск гостя) успевает убить нас.
*
* Почему не через `top_app` и не через провайдер: `Secure.top_app` лаунчер сам переписывает
* по реальному топу, а `ContentProvider.getCallingPackage()` на API 31+ сверяет пакет с
* реальным uid вызывающего — shell (uid 2000) не может представиться `com.leiniao.camera`.
*/
fun ensureMercuryKillGuard() {
if (guardWatchdog != null) return
guardWatchdog = testScope.launch {
while (isActive) {
if (guardDisplayId <= 0) {
startGuardOnce()
delay(GUARD_RETRY_MS)
} else {
launchGuardActivity()
delay(GUARD_RELAUNCH_MS)
}
}
}
}
/** Создаёт служебный дисплей и активность-держатель флага. Вызывается повторно до успеха. */
private suspend fun startGuardOnce() {
val ctx = appContext ?: return
val surfaceTexture = try {
// Дисплей с невалидной Surface считается OFF, и активность на нём не стартует.
SurfaceTexture(false).also { it.setDefaultBufferSize(GUARD_WIDTH, GUARD_HEIGHT) }
} catch (t: Throwable) {
Log.w(TAG, "startGuardOnce: SurfaceTexture failed", t)
return
}
val client = ShizukuVdClient(ctx, VmShizukuService::class.java)
val binder = try {
client.bindForResult(timeoutMs = 5000)
} catch (t: Throwable) {
Log.w(TAG, "startGuardOnce: bind failed", t)
null
}
if (binder == null) {
Log.w(TAG, "startGuardOnce: Shizuku binder не получен (нет прав?)")
runCatching { client.stop() }
surfaceTexture.release()
return
}
val displayId = try {
ShizukuVdBinder.createDisplay(
binder = binder,
name = GUARD_DISPLAY_NAME,
width = GUARD_WIDTH,
height = GUARD_HEIGHT,
density = VIRTUAL_DENSITY,
surface = Surface(surfaceTexture),
)
} catch (t: Throwable) {
Log.w(TAG, "startGuardOnce: createDisplay failed", t)
0
}
if (displayId <= 0) {
Log.w(TAG, "startGuardOnce: createDisplay вернул displayId=$displayId")
runCatching { client.stop() }
surfaceTexture.release()
return
}
guardClient = client
guardBinder = binder
guardDisplayId = displayId
guardSurfaceTexture = surfaceTexture
launchGuardActivity()
Log.i(TAG, "startGuardOnce: страж поднят на displayId=$displayId")
}
/**
* Гасит Mercury-«страж»: watchdog, выключатель, служебный дисплей и его UserService.
* Идемпотентно. Без этого после остановки приложения остаются висеть невидимый дисплей
* и процесс `:shizuku_vd` (см. правило #4847).
*/
fun stopMercuryKillGuard() {
guardWatchdog?.cancel()
guardWatchdog = null
val binder = guardBinder
val displayId = guardDisplayId
val client = guardClient
val texture = guardSurfaceTexture
guardBinder = null
guardDisplayId = 0
guardClient = null
guardSurfaceTexture = null
if (binder == null && displayId <= 0 && client == null && texture == null) return
Log.i(TAG, "stopMercuryKillGuard: displayId=$displayId")
testScope.launch {
if (binder != null) {
runCatching { ShizukuVdBinder.stopKillGuard(binder) }
if (displayId > 0) runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
}
runCatching { texture?.release() }
// Процесс UserService надо именно убить: unbind(remove=true) его не завершает (#4847).
if (binder != null) runCatching { ShizukuVdBinder.destroy(binder) }
runCatching { client?.stop() }
}
}
/** Watchdog-перезапуск активности стража: если система её прибила, флаг надо вернуть. */
private fun launchGuardActivity() {
val binder = guardBinder ?: return
val displayId = guardDisplayId
if (displayId <= 0) return
try {
ShizukuVdBinder.launchActivity(
binder = binder,
packageName = GUARD_PACKAGE,
className = GUARD_ACTIVITY,
displayId = displayId,
)
} catch (t: Throwable) {
// Binder умер (Shizuku-сервер перезапускался/упал): старый UserService бесполезен,
// его надо выбросить и поднять новый — иначе watchdog вечно долбит мёртвый binder
// (DeadObjectException каждые 5с), а стража на экране нет.
if (t is android.os.RemoteException || t is IllegalStateException) {
Log.w(TAG, "launchGuardActivity: binder мёртв (displayId=$displayId) — пересоздаю стража", t)
resetGuardState()
} else {
Log.w(TAG, "launchGuardActivity failed (displayId=$displayId)", t)
}
}
}
/** Сбрасывает состояние стража (без остановки watchdog), чтобы тот поднял его заново. */
private fun resetGuardState() {
val binder = guardBinder
val displayId = guardDisplayId
val client = guardClient
val texture = guardSurfaceTexture
guardBinder = null
guardDisplayId = 0
guardClient = null
guardSurfaceTexture = null
testScope.launch {
runCatching { texture?.release() }
if (binder != null) {
if (displayId > 0) runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
runCatching { ShizukuVdBinder.destroy(binder) }
}
runCatching { client?.stop() }
}
} }
fun setAppContext(context: Context) { fun setAppContext(context: Context) {
@@ -158,9 +682,54 @@ class VmGlassesController {
companion object { companion object {
private const val TAG = "VmGlassesController" private const val TAG = "VmGlassesController"
private const val VIRTUAL_WIDTH = 640 private const val VIRTUAL_WIDTH = VmDisplay.WIDTH
private const val VIRTUAL_HEIGHT = 480 private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
private const val VIRTUAL_DENSITY = 160 private const val VIRTUAL_DENSITY = VmDisplay.DENSITY
/** Скролл-драг: как часто сливаем накопленные дельты, порог и сброс «хвоста». */
private const val SCROLL_TICK_MS = 90L
/** Через сколько мс простоя тачпада прятать курсор. */
private const val CURSOR_HIDE_MS = 3_000L
/** Минимальная длина драга: короче гость трактует как клик по элементу (проверено на Settings ~50-60 px). */
private const val SCROLL_MIN_PX = 96f
/** Если дельты пропали дольше этого — накопленное не тащим в следующий жест. */
private const val SCROLL_IDLE_RESET_MS = 300L
/** Активность RayNeo-лаунчера, чей `onStart` включает вендорский `tmpStopBackgroundKill`. */
private const val GUARD_PACKAGE = "com.ffalconxr.mercury.launcher"
private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
/** Наш пакет — не убиваем себя при «убить всё» (см. [forceStopAll]). */
private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
/** Shizuku — не убиваем: через его UserService и выполняем force-stop. */
private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
/**
* RayNeo XR-runtime. Формально системный (`system_ext/priv-app`), но именно он держит
* открытой аппаратную камеру и жрёт ~100% CPU — поэтому его принудительно снимаем.
*/
private const val XR_RUNTIME_PACKAGE = "com.rayneo.xr.runtime"
/** Подстрока командной строки процессов-зеркал (scrcpy), которые тоже добиваем. */
private const val SCRCPY_PROCESS = "scrcpy"
private const val GUARD_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240
/** Как часто перепроверяем, что активность стража жива. */
private const val GUARD_RELAUNCH_MS = 5_000L
/** Пауза перед повторной попыткой поднять стража (например, Shizuku ещё не дал права). */
private const val GUARD_RETRY_MS = 3_000L
/** Пауза после force-stop гостя: AMS должен успеть снять задачу до release дисплея. */
private const val GUEST_KILL_SETTLE_MS = 300L
val Local = staticCompositionLocalOf<VmGlassesController> { val Local = staticCompositionLocalOf<VmGlassesController> {
error("VmGlassesController not provided") error("VmGlassesController not provided")
@@ -176,16 +745,48 @@ interface ControllerProvider {
fun provideController(): VmGlassesController fun provideController(): VmGlassesController
} }
/** Позиция курсора мыши в координатах виртуального дисплея гостя. */
data class CursorPosition(val x: Float, val y: Float)
/** /**
* DEBUG broadcast receiver для тестирования через adb: * DEBUG broadcast receiver для тестирования через adb.
*
* Android 12+ блокирует доставку broadcast-ов в manifest-объявленные receiver-ы если
* процесс в фоне ("Background execution not allowed"). Поэтому регистрируем программно
* из MainActivity.onCreate() — это runtime-register receiver, exempt от background-check.
*
* Использование:
* adb shell am broadcast -a pw.binom.rayneovm.glasses.TEST -e package free.zona * adb shell am broadcast -a pw.binom.rayneovm.glasses.TEST -e package free.zona
*/ */
class DebugTestReceiver : BroadcastReceiver() { class DebugTestReceiver : BroadcastReceiver() {
private var controller: VmGlassesController? = null
private var registeredContext: Context? = null
/** Вызывается из MainActivity.onCreate (когда приложение в foreground). */
fun attach(context: Context, controller: VmGlassesController) {
this.controller = controller
val filter = IntentFilter(ACTION)
// RECEIVER_EXPORTED — можно принимать от adb shell; pre-Android 13 можно было
// без флага, с 13+ обязательно указывать.
context.registerReceiver(this, filter, Context.RECEIVER_EXPORTED)
registeredContext = context
Log.i(TAG, "DebugTestReceiver registered")
}
fun detach(context: Context) {
try {
context.unregisterReceiver(this)
} catch (_: Throwable) {
// not registered, ignore
}
}
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return if (intent.action != ACTION) return
val pkg = intent.getStringExtra("package") ?: return val pkg = intent.getStringExtra("package") ?: return
Log.i(TAG, "DebugTestReceiver: launching $pkg") Log.i(TAG, "DebugTestReceiver: launching $pkg")
val controller = (context.applicationContext as? ControllerProvider)?.provideController() val controller = controller
?: (context.applicationContext as? ControllerProvider)?.provideController()
?: return ?: return
controller.startTestLaunch(pkg) controller.startTestLaunch(pkg)
} }
@@ -25,14 +25,27 @@ class VirtualDisplayFrameSource private constructor(
private val surface: Surface, private val surface: Surface,
private val binder: IBinder, private val binder: IBinder,
private val displayId: Int, private val displayId: Int,
private val client: ShizukuVdClient,
width: Int, width: Int,
height: Int, height: Int,
) : SurfaceTextureFrameSource(source, width, height) { ) : SurfaceTextureFrameSource(source, width, height) {
/** ID виртуального дисплея (нужен для запуска Activity через ActivityOptions.setLaunchDisplayId). */
val virtualDisplayId: Int get() = displayId
/** Binder к ShizukuVdService (для последующих вызовов launchActivity / injectInputEvent). */
val shizukuBinder: IBinder get() = binder
override fun release() { override fun release() {
runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) } runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
runCatching { surface.release() } runCatching { surface.release() }
super.release() super.release()
// Убиваем процесс UserService: `unbindUserService(remove = true)` сам по себе его не
// завершает (Shizuku держит UserService живым для переиспользования), и после каждого
// запуска гостя оставался висеть процесс-сирота (~13 МБ). `destroy` шлёт System.exit.
runCatching { ShizukuVdBinder.destroy(binder) }
// Отвязываем UserService — держим его живым ровно на время жизни дисплея.
runCatching { client.stop() }
Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId") Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId")
} }
@@ -60,10 +73,10 @@ class VirtualDisplayFrameSource private constructor(
val surface = Surface(surfaceTexture) val surface = Surface(surfaceTexture)
val client = ShizukuVdClient(context, serviceClass) val client = ShizukuVdClient(context, serviceClass)
return try { return try {
var binder: IBinder? = null // bindUserService асинхронен — ждём Binder через suspend-обёртку (5с таймаут).
client.start { b -> binder = b } val b = client.bindForResult(timeoutMs = 5000)
val b = binder ?: run { if (b == null) {
Log.w(TAG, "Shizuku binder not ready, returning null") Log.w(TAG, "Shizuku binder not ready (timeout или pingBinder=false), returning null")
surface.release() surface.release()
client.stop() client.stop()
return null return null
@@ -78,7 +91,7 @@ class VirtualDisplayFrameSource private constructor(
return null return null
} }
Log.i(TAG, "Acquired VirtualDisplay id=$displayId ${width}x${height}") Log.i(TAG, "Acquired VirtualDisplay id=$displayId ${width}x${height}")
VirtualDisplayFrameSource(surfaceTexture, surface, b, displayId, width, height) VirtualDisplayFrameSource(surfaceTexture, surface, b, displayId, client, width, height)
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "acquire failed", t) Log.e(TAG, "acquire failed", t)
runCatching { surface.release() } runCatching { surface.release() }
@@ -1,5 +1,6 @@
package pw.binom.rayneovm.glasses.ui package pw.binom.rayneovm.glasses.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -12,16 +13,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import pw.binom.mercury.binocular.Binocular
import pw.binom.mercury.binocular.BinocularRendererView import pw.binom.mercury.binocular.BinocularRendererView
import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.binocular.NoOpFrameSource import pw.binom.mercury.binocular.NoOpFrameSource
import pw.binom.rayneovm.glasses.protocol.VmGlassesController import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.vm.GlassesScreenState import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -42,49 +44,94 @@ fun GlassesScreen(
onRequestShizukuPermission: (() -> Unit)? = null, onRequestShizukuPermission: (() -> Unit)? = null,
) { ) {
val state by controller.state.collectAsState() val state by controller.state.collectAsState()
val frameSource = remember { mutableStateOf<FrameSource>(NoOpFrameSource()) } val controllerFrameSource by controller.frameSource.collectAsState()
val cursor by controller.cursor.collectAsState()
val hud = rememberStatusHudData()
val noOp = remember { NoOpFrameSource() }
val frameSource = controllerFrameSource ?: noOp
Box(modifier = modifier.fillMaxSize().background(Color.Black)) { Box(modifier = modifier.fillMaxSize().background(Color.Black)) {
// GL-слой: всегда рисуем, но при NoOpFrameSource — просто чёрный экран // GL-слой: всегда рисуем, но при NoOpFrameSource — просто чёрный экран
BinocularRendererView( BinocularRendererView(
frameSource = frameSource.value, frameSource = frameSource,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Compose overlay поверх GL // Compose overlay поверх GL. Простые статусы рисуем бинокулярно (по тексту на каждый глаз)
// через [Binocular]; GL-слой и курсор мыши внутри Binocular НЕ оборачиваем — они сами
// справляются (рендер и точки в обеих половинах).
when (val s = state) { when (val s = state) {
is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...") is GlassesScreenState.Starting -> Binocular { StatusOverlay("Подготавливаем очки...") }
is GlassesScreenState.ShizukuMissing -> StatusOverlay( is GlassesScreenState.ShizukuMissing -> Binocular {
StatusOverlay(
title = "Shizuku не активен", title = "Shizuku не активен",
subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" + subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" +
"Тапните здесь, чтобы дать разрешение.", "Тапните здесь, чтобы дать разрешение.",
onClick = onRequestShizukuPermission, onClick = onRequestShizukuPermission,
) )
is GlassesScreenState.NoConnection -> StatusOverlay( }
is GlassesScreenState.NoConnection -> Binocular {
StatusOverlay(
title = "Нет связи с телефоном", title = "Нет связи с телефоном",
subtitle = "Убедитесь, что телефон и очки в одной WiFi-сети." subtitle = "Убедитесь, что телефон и очки в одной WiFi-сети."
) )
is GlassesScreenState.NoAppSelected -> StatusOverlay( }
is GlassesScreenState.NoAppSelected -> Binocular {
StatusOverlay(
title = "Выберите приложение на телефоне", title = "Выберите приложение на телефоне",
subtitle = "Откройте приложение «RayNeo VM» и выберите приложение для запуска.\n\n[DEBUG] Тап-тест: запустить Zona сейчас", subtitle = "Откройте приложение «RayNeo VM» и выберите приложение для запуска.\n\n[DEBUG] Тап-тест: запустить Zona сейчас",
onClick = { onClick = {
controller.startTestLaunch("free.zona") controller.startTestLaunch("free.zona")
} }
) )
}
is GlassesScreenState.Running -> { is GlassesScreenState.Running -> {
// Ничего поверх GL — пусть пользователь видит гостевой контент. // Ничего поверх GL, кроме курсора мыши: FrameSource уже установлен контроллером.
// FrameSource обновляется в LaunchedEffect ниже. cursor?.let { CursorOverlay(it.x, it.y) }
androidx.compose.runtime.LaunchedEffect(s.packageName) {
val src = controller.acquireFrameSource(s.packageName)
if (src != null) frameSource.value = src
} }
} is GlassesScreenState.Error -> Binocular {
is GlassesScreenState.Error -> StatusOverlay( StatusOverlay(
title = "Ошибка", title = "Ошибка",
subtitle = s.message, subtitle = s.message,
) )
} }
} }
// HUD (время + заряд) — всегда поверх, в обеих половинах (на обоих глазах),
// сверху по центру. Виден в любом состоянии: и в ожидании связи, и поверх гостя.
Binocular {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
StatusHud(hud, modifier = Modifier.padding(top = 6.dp))
}
}
}
}
/**
* Курсор мыши поверх GL-слоя.
*
* Экран физически 1280×480: левая половина — левый глаз, правая — правый.
* Точку гостя (x, y) рисуем в обеих половинах на одном и том же y.
*/
@Composable
private fun CursorOverlay(x: Float, y: Float) {
Canvas(modifier = Modifier.fillMaxSize()) {
val half = size.width / 2f
val radius = 10f
for (cx in floatArrayOf(x, x + half)) {
val center = Offset(cx, y)
drawCircle(color = Color(0xFFFFEB3B), radius = radius, center = center)
drawCircle(
color = Color(0xCC000000),
radius = radius,
center = center,
style = Stroke(width = 3f),
)
}
}
} }
@Composable @Composable
@@ -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
}
@@ -1,3 +1,5 @@
<resources> <resources>
<string name="app_name">RayNeo VM</string> <string name="app_name">RayNeo VM</string>
<string name="vm_accessibility_summary">RayNeo VM</string>
<string name="vm_accessibility_description">Фоновый сервис RayNeo VM. Держит приложение работоспособным в фоне и позволяет ему самостоятельно запускаться. Не читает содержимое экрана и не управляет другими приложениями.</string>
</resources> </resources>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Конфиг Accessibility-сервиса RayNeo VM.
Сервис — «легальный» живучий процесс, который система поднимает/перезапускает сама,
через который можно открывать Activity из фона, и — главное — единственный
корректный путь ввода текста в поле гостя на VirtualDisplay без IME
(ACTION_SET_TEXT). Чужой ввод при этом НЕ инжектится.
eventTypes: typeWindowStateChanged|typeViewFocused — второе нужно, чтобы ловить
фокус поля ввода (в будущем).
canRetrieveWindowContent=true — иначе ACCESSIBILITY недоступен rootInActiveWindow и
ACTION_SET_TEXT не выполнить. flagRetrieveInteractiveWindows — чтобы видеть окна
гостя на VirtualDisplay, а не только окно по умолчанию.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:description="@string/vm_accessibility_description"
android:notificationTimeout="100"
android:summary="@string/vm_accessibility_summary" />
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
IME-метод для [VmNoopImeService]. Никакой UI, никакого ввода — сервис существует лишь
чтобы система не поднимала системный IME, когда гость (Zona) получает фокус в поле ввода.
Включается вторично через настройки системного уровня (см. ShizukuProvisioner).
-->
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:isDefault="false"
android:supportsSwitchingToNextInputMethod="false" />
+21
View File
@@ -4,6 +4,17 @@ plugins {
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
} }
// adb-ключ рабочей машины (уже авторизован на очках) зашивается в APK, чтобы
// телефон мог подключиться к очкам по wireless-debugging без сопряжения.
// Ключ НЕ коммитится — берётся из ~/.android при сборке.
fun String.toBuildConfigString(): String =
"\"" + replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "\\n") + "\""
val adbKeyFile = File(System.getProperty("user.home"), ".android/adbkey")
val adbPubKeyFile = File(System.getProperty("user.home"), ".android/adbkey.pub")
val adbPrivateKeyConst = if (adbKeyFile.exists()) adbKeyFile.readText().trim().toBuildConfigString() else "\"\""
val adbPublicKeyConst = if (adbPubKeyFile.exists()) adbPubKeyFile.readText().trim().toBuildConfigString() else "\"\""
android { android {
namespace = "pw.binom.rayneovm.phone" namespace = "pw.binom.rayneovm.phone"
compileSdk = 37 compileSdk = 37
@@ -14,6 +25,9 @@ android {
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
buildConfigField("String", "ADB_PRIVATE_KEY", adbPrivateKeyConst)
buildConfigField("String", "ADB_PUBLIC_KEY", adbPublicKeyConst)
} }
buildTypes { buildTypes {
@@ -29,12 +43,19 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
// libadb.so — исполняемый бинарник adb, а не shared-библиотека. Чтобы его можно
// было запускать из nativeLibraryDir (единственное разрешённое место на W^X),
// включаем legacy-packaging: jniLibs распаковываются на диск при установке.
jniLibs {
useLegacyPackaging = true
}
} }
} }
+23
View File
@@ -39,10 +39,33 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Хранитель связи: foreground service, не даёт системе усыпить процесс при
выключенном экране (иначе очки теряют WebSocket-связь). -->
<service
android:name=".ConnectionKeepAliveService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- Foreground service для фонового воспроизведения аудио --> <!-- Foreground service для фонового воспроизведения аудио -->
<service <service
android:name=".audio.AudioPlaybackService" android:name=".audio.AudioPlaybackService"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback" /> android:foregroundServiceType="mediaPlayback" />
<!-- Диагностика: `adb shell am broadcast -a pw.binom.rayneovm.phone.DEBUG_PROVISION`
принудительно запускает автономный подъём Shizuku. Защищено DUMP (есть у shell). -->
<receiver
android:name=".debug.DebugReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="pw.binom.rayneovm.phone.DEBUG_PROVISION" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_BRIGHTNESS" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_ADB_SHELL" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_AUDIO" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,87 @@
package pw.binom.rayneovm.phone
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Foreground service-«хранитель связи»: не даёт системе усыпить процесс телефона при
* выключенном экране.
*
* Без него HONOR замораживает/убивает фоновый процесс, и очки теряют WebSocket-связь
* (механика подсмотрена в view-mate/app-phone PlaybackService). PARTIAL_WAKE_LOCK держит CPU,
* WIFI_MODE_FULL_HIGH_PERF — радио; сам факт foreground-сервиса поднимает приоритет процесса,
* поэтому он не уходит в глубокий сон вместе с экраном.
*
* Стартует в [PhoneVmApp.onCreate], останавливается в [PhoneVmApp.onTerminate].
*/
class ConnectionKeepAliveService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel(
CHANNEL_ID, "Связь с очками",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
startForeground(NOTIFICATION_ID, buildNotification())
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "rayneovm:connection")
?.apply { acquire() }
wifiLock = (applicationContext.getSystemService(WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "rayneovm:wifi")
?.apply { acquire() }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
override fun onDestroy() {
wakeLock?.takeIf { it.isHeld }?.release()
wifiLock?.takeIf { it.isHeld }?.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(): Notification {
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle("RayNeo VM")
.setContentText("Связь с очками активна")
.setContentIntent(openApp)
.setOngoing(true)
.build()
}
companion object {
private const val CHANNEL_ID = "vm_connection"
private const val NOTIFICATION_ID = 301
fun start(context: Context) {
ContextCompat.startForegroundService(
context, Intent(context, ConnectionKeepAliveService::class.java),
)
}
fun stop(context: Context) {
context.stopService(Intent(context, ConnectionKeepAliveService::class.java))
}
}
}
@@ -1,5 +1,8 @@
package pw.binom.rayneovm.phone package pw.binom.rayneovm.phone
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -11,8 +14,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.core.content.ContextCompat
import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.VmPhoneController import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.phone.ui.HomeScreen import pw.binom.rayneovm.phone.ui.HomeScreen
@@ -27,6 +32,13 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
val controller = VmPhoneController.fromContext(this) val controller = VmPhoneController.fromContext(this)
val mercuryController = MercuryController.fromContext(this) val mercuryController = MercuryController.fromContext(this)
val provisioner = (application as ShizukuProvisionerProvider).provideShizukuProvisioner()
// Диагностика: `am start ... --es address ip:port --ez provision true` прогоняет
// бутстрап сразу при запуске (когда broadcast доставить не удаётся).
if (intent?.getBooleanExtra("provision", false) == true) {
val address = intent.getStringExtra("address")
if (address.isNullOrBlank()) provisioner.force() else provisioner.forceWithAddress(address)
}
setContent { setContent {
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(), contract = ActivityResultContracts.RequestMultiplePermissions(),
@@ -34,6 +46,20 @@ class MainActivity : ComponentActivity() {
val allGranted = result.values.all { it } val allGranted = result.values.all { it }
mercuryController.onPermissionsResult(allGranted) mercuryController.onPermissionsResult(allGranted)
} }
// POST_NOTIFICATIONS нужен, чтобы foreground-«хранитель связи» показал уведомление
// и не был придушен системой (Android 13+).
val notificationLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* результат не критичен: сервис работает и без видимого уведомления */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
CompositionLocalProvider( CompositionLocalProvider(
VmPhoneController.Local provides controller, VmPhoneController.Local provides controller,
MercuryController.Local provides mercuryController, MercuryController.Local provides mercuryController,
@@ -47,6 +73,7 @@ class MainActivity : ComponentActivity() {
HomeScreen( HomeScreen(
controller = controller, controller = controller,
mercuryController = mercuryController, mercuryController = mercuryController,
provisioner = provisioner,
onRequestMercuryPermissions = { onRequestMercuryPermissions = {
permissionLauncher.launch(mercuryController.requiredPermissions) permissionLauncher.launch(mercuryController.requiredPermissions)
}, },
@@ -2,8 +2,19 @@ package pw.binom.rayneovm.phone
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.bootstrap.AccessibilityProvisioner
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
import pw.binom.rayneovm.phone.mercury.MercuryState
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.ControllerProvider import pw.binom.rayneovm.phone.protocol.ControllerProvider
import pw.binom.rayneovm.phone.protocol.VmPhoneController import pw.binom.rayneovm.phone.protocol.VmPhoneController
@@ -13,29 +24,121 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
* При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS) * При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS)
* и [MercuryController] (второй канал — RayNeo Mercury BLE). * и [MercuryController] (второй канал — RayNeo Mercury BLE).
*/ */
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider { class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
ShizukuProvisionerProvider, AdbControllerProvider, AccessibilityProvisionerProvider {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controller: VmPhoneController by lazy { VmPhoneController(this) } private val controller: VmPhoneController by lazy { VmPhoneController(this) }
private val mercuryController: MercuryController by lazy { MercuryController(this) } private val mercuryController: MercuryController by lazy { MercuryController(this) }
private val adbController: AdbController by lazy { AdbController(this, mercuryController) }
private val provisioner: ShizukuProvisioner by lazy {
ShizukuProvisioner(this, mercuryController, controller, adbController)
}
private val accessibilityProvisioner: AccessibilityProvisioner by lazy {
AccessibilityProvisioner(controller, adbController)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(TAG, "PhoneVmApp onCreate, uid=${android.os.Process.myUid()}") Log.i(TAG, "PhoneVmApp onCreate, uid=${android.os.Process.myUid()}")
// Хранитель связи: держит процесс живым при выключенном экране, иначе HONOR усыпляет
// его и очки теряют WebSocket-связь. Может быть запрещён системой при старте из фона —
// тогда связь просто отвалится по экрану, как раньше; приложение не падает.
try {
ConnectionKeepAliveService.start(this)
} catch (t: Throwable) {
Log.w(TAG, "ConnectionKeepAliveService start failed", t)
}
// Запуск отложен до первого обращения — в lazy. // Запуск отложен до первого обращения — в lazy.
controller.start() controller.start()
mercuryController.refresh() mercuryController.refresh()
// Автономный подъём Shizuku. Условие: есть и WiFi-канал (наша WebSocket-связь
// с очками), и BLE (Канал 2, через который очки включают ADB over WiFi).
// Проверяем при каждом появлении связи и периодически, пока BLE жив, —
// если Shizuku не запущен, поднимаем его.
appScope.launch {
controller.state.collect { st ->
if (st is ConnectionState.Connected && mercuryController.state.value is MercuryState.Connected) {
Log.i(TAG, "WiFi подключение к очкам — проверяю Shizuku")
provisioner.ensure()
}
}
}
// Периодический дешёвый дозвон, пока BLE жив. Полный adb-цикл гоняется не чаще
// одного раза за BLE-сессию (см. ShizukuProvisioner.provisionedInSession), поэтому
// это не дёргает очки; зато так мы подхватываем случай, когда Shizuku упал и очки
// прислали явный статус running=false.
appScope.launch {
while (true) {
delay(PROVISION_RETRY_MS)
if (mercuryController.state.value is MercuryState.Connected) {
provisioner.ensure()
}
}
}
// Разрыв BLE — новая сессия: при следующем подключении проверяем Shizuku заново.
appScope.launch {
mercuryController.state.collect { st ->
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()
}
}
}
} }
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
ConnectionKeepAliveService.stop(this)
provisioner.close()
accessibilityProvisioner.close()
controller.stop() controller.stop()
mercuryController.close() mercuryController.close()
} }
override fun provideController(): VmPhoneController = controller override fun provideController(): VmPhoneController = controller
override fun provideMercuryController(): MercuryController = mercuryController override fun provideMercuryController(): MercuryController = mercuryController
override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner
override fun provideAdbController(): AdbController = adbController
override fun provideAccessibilityProvisioner(): AccessibilityProvisioner = accessibilityProvisioner
companion object { companion object {
private const val TAG = "PhoneVmApp" private const val TAG = "PhoneVmApp"
private const val PROVISION_RETRY_MS = 30_000L
} }
} }
/** Провайдер автономного бутстрапа Shizuku для UI. */
interface ShizukuProvisionerProvider {
fun provideShizukuProvisioner(): ShizukuProvisioner
}
/** Провайдер автовключения Accessibility-сервиса на очках для UI. */
interface AccessibilityProvisionerProvider {
fun provideAccessibilityProvisioner(): AccessibilityProvisioner
}
/** Провайдер adb-контроллера (единая точка входа для adb-команд) для UI. */
interface AdbControllerProvider {
fun provideAdbController(): AdbController
}
@@ -0,0 +1,131 @@
package pw.binom.rayneovm.phone.adb
import android.content.Context
import android.util.Log
import pw.binom.rayneovm.phone.BuildConfig
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
/**
* Тонкая обёртка над встроенным adb-бинарником (`jniLibs/arm64-v8a/libadb.so`).
*
* Это настоящий AOSP `adb`, запущенный как отдельный процесс из nativeLibraryDir
* (тот же приём, что в LADB). Нужен, чтобы с телефона по wireless-debugging
* (TLS) подключиться к очкам и поднять там Shizuku — без ПК и без ручных кнопок.
*
* adb ищет клиентский ключ в `$HOME/.android/adbkey`, поэтому подменяем HOME на
* filesDir и кладём туда ключ рабочей машины (он уже авторизован в adb_keys очков).
*/
class AdbClient(private val context: Context) {
/**
* `libadb.so` — исполняемый файл, а не shared-библиотека. Система всё равно
* распаковывает jniLibs в nativeLibraryDir (см. `useLegacyPackaging = true`),
* а это единственное место, откуда приложению разрешено `exec` на Android 10+
* (W^X запрещает запуск из filesDir/cacheDir).
*/
private val adbFile: File
get() = File(context.applicationInfo.nativeLibraryDir, "libadb.so")
private val adbPath: String get() = adbFile.absolutePath
/** adb ищет клиентский ключ в `$HOME/.android/adbkey` (в сборке LADB — в `$HOME/adbkey`).
* Совмещаем: HOME = filesDir/.android, ключ кладём и туда, и в `.android/.android`. */
private val home: File get() = File(context.filesDir, ".android").apply { mkdirs() }
/** Бинарник на месте и ключ зашит в APK. */
val available: Boolean
get() = adbFile.canExecute() &&
BuildConfig.ADB_PRIVATE_KEY.isNotBlank() &&
BuildConfig.ADB_PUBLIC_KEY.isNotBlank()
/**
* Раскладывает adb-ключ в `$HOME/.android/`, если он ещё не там / изменился.
* Возвращает false, если ключ не зашит в сборку.
*/
fun installKey(): Boolean {
if (BuildConfig.ADB_PRIVATE_KEY.isBlank() || BuildConfig.ADB_PUBLIC_KEY.isBlank()) return false
val privText = BuildConfig.ADB_PRIVATE_KEY.replace("\\n", "\n")
val pubText = BuildConfig.ADB_PUBLIC_KEY.replace("\\n", "\n")
// Кладём ключ в обе возможные точки поиска — какая сборка adb как ищет, неизвестно.
val dirs = listOf(home, File(home, ".android"))
dirs.forEach { dir ->
dir.mkdirs()
val priv = File(dir, "adbkey")
val pub = File(dir, "adbkey.pub")
if (!priv.exists() || priv.readText() != privText) priv.writeText(privText)
if (!pub.exists() || pub.readText() != pubText) pub.writeText(pubText)
priv.setReadable(true, true)
priv.setWritable(true, true)
}
return true
}
data class Result(val exitCode: Int, val output: String) {
val ok: Boolean get() = exitCode == 0
}
/** `adb start-server` (поднимает локальный adb-сервер приложения). */
fun startServer(timeoutMs: Long = 60_000): Result = exec(listOf("start-server"), timeoutMs)
/** `adb connect host:port`. */
fun connect(hostPort: String, timeoutMs: Long = 30_000): Result =
exec(listOf("connect", hostPort), timeoutMs)
/** `adb disconnect host:port`. */
fun disconnect(hostPort: String, timeoutMs: Long = 15_000): Result =
exec(listOf("disconnect", hostPort), timeoutMs)
/** `adb -s <serial> shell <command>`. */
fun shell(serial: String, command: String, timeoutMs: Long = 60_000): Result =
exec(listOf("-s", serial, "shell", command), timeoutMs)
/** `adb -s <serial> push <local> <remote>`. */
fun push(serial: String, local: File, remote: String, timeoutMs: Long = 60_000): Result =
exec(listOf("-s", serial, "push", local.absolutePath, remote), timeoutMs)
/** Устройство реально отвечает по serial. */
fun isConnected(serial: String, timeoutMs: Long = 10_000): Boolean =
shell(serial, "echo ok", timeoutMs = timeoutMs).output.contains("ok")
private fun exec(args: List<String>, timeoutMs: Long): Result {
val cmd = listOf(adbPath) + args
Log.d(TAG, "exec: ${args.joinToString(" ")}")
val process = try {
ProcessBuilder(cmd).directory(home).redirectErrorStream(true).apply {
environment()["HOME"] = home.absolutePath
// adb пишет свой лог/сокеты в TMPDIR; по умолчанию это /tmp недоступно приложению.
environment()["TMPDIR"] = context.cacheDir.absolutePath
environment()["ANDROID_USER_CONFIG"] = home.absolutePath
}.start()
} catch (t: Throwable) {
Log.w(TAG, "не удалось запустить adb", t)
return Result(-1, "adb spawn failed: ${t.message}")
}
val output = StringBuilder()
val reader = Thread {
try {
process.inputStream.bufferedReader().forEachLine { output.appendLine(it) }
} catch (_: Throwable) {
}
}.apply { isDaemon = true; start() }
val finished = try {
process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
} catch (_: InterruptedException) {
false
}
if (!finished) process.destroyForcibly()
reader.join(500)
val code = if (finished) runCatching { process.exitValue() }.getOrDefault(-1) else -1
val text = output.toString().trim()
Log.d(TAG, "exit=$code out=${text.take(400)}")
return Result(code, text)
}
private companion object {
const val TAG = "AdbClient"
}
}
@@ -0,0 +1,270 @@
package pw.binom.rayneovm.phone.adb
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import pw.binom.rayneovm.phone.mercury.MercuryController
import java.io.File
import kotlin.coroutines.resume
/**
* Единая точка входа для всех adb-вызовов к очкам.
*
* Зачем: wireless-adb на очках **сам гаснет**, как только они теряют WiFi-сеть, к которой
* он был привязан (`adb_wifi_enabled` → 0, порт → -1), и не переживает ребут. Поэтому
* хранить «готовое» соединение бессмысленно — его надо каждый раз проверять и при
* необходимости поднимать заново.
*
* Контракт: **все** команды идут через [shell] / [push]; каждый вызов сначала
* [ensureConnected] — если соединение живо, оно переиспользуется (кэш `ip:port` + дешёвый
* `echo ok`), если мертво — wireless-adb включается через Mercury, адрес при
* необходимости ищется по mDNS `_adb-tls-connect._tcp`, и adb подключается заново.
*
* Мьютекс держится **только на время подъёма** соединения, поэтому длинный бутстрап
* Shizuku не блокирует пользовательские команды (кнопки в UI).
*/
class AdbController(
context: Context,
private val mercury: MercuryController,
) {
private val appContext = context.applicationContext
private val adb = AdbClient(appContext)
/** Только на подъём соединения: сами команды после него идут без блокировки. */
private val connectMutex = Mutex()
@Volatile
private var address: String? = null
private val _state = MutableStateFlow<AdbConnectionState>(AdbConnectionState.Disconnected)
val state: StateFlow<AdbConnectionState> = _state.asStateFlow()
/** Бинарник adb и ключ встроены в сборку. */
val available: Boolean get() = adb.available
/**
* Живой serial очков (`ip:port`), поднимая соединение при необходимости.
*
* Быстрый путь: сохранённый адрес + `echo ok` (≤ [PROBE_TIMEOUT_MS]). Если не отвечает —
* полный подъём: Mercury включает wireless-adb → адрес от Mercury или mDNS → `adb connect`.
*
* @return serial для [AdbClient] или null, если поднять не удалось (см. [state]).
*/
suspend fun ensureConnected(): String? = withContext(Dispatchers.IO) {
if (!adb.available) {
fail("adb-бинарник или ключ не встроены в сборку")
return@withContext null
}
address?.let { cached ->
if (adb.isConnected(cached, timeoutMs = PROBE_TIMEOUT_MS)) {
_state.value = AdbConnectionState.Connected(cached)
return@withContext cached
}
Log.i(TAG, "соединение с $cached протухло — поднимаем заново")
address = null
}
connectMutex.withLock {
// Пока ждали мьютекс, подъём мог уже случиться в другом вызове.
address?.let { cached ->
if (adb.isConnected(cached, timeoutMs = PROBE_TIMEOUT_MS)) {
_state.value = AdbConnectionState.Connected(cached)
return@withLock cached
}
address = null
}
bringUp()?.also { address = it }
}
}
/**
* Выполнить shell-команду на очках (`adb -s <serial> shell ...`). Соединение проверяется
* и при необходимости поднимается автоматически.
*/
suspend fun shell(command: String, timeoutMs: Long = DEFAULT_SHELL_TIMEOUT_MS): Result<String> =
withContext(Dispatchers.IO) {
val serial = ensureConnected()
?: return@withContext Result.failure(
IllegalStateException("нет adb-соединения с очками (wireless-adb не поднялся?)"),
)
val result = adb.shell(serial, command, timeoutMs = timeoutMs)
Log.i(TAG, "shell($serial): $command -> exit=${result.exitCode} ${result.output.take(200)}")
if (result.ok) {
Result.success(result.output)
} else {
Result.failure(IllegalStateException(result.output.ifBlank { "exit=${result.exitCode}" }))
}
}
/** Залить файл на очки (`adb push`). Соединение поднимается автоматически. */
suspend fun push(
local: File,
remote: String,
timeoutMs: Long = DEFAULT_PUSH_TIMEOUT_MS,
): Result<String> = withContext(Dispatchers.IO) {
val serial = ensureConnected()
?: return@withContext Result.failure(
IllegalStateException("нет adb-соединения с очками (wireless-adb не поднялся?)"),
)
val result = adb.push(serial, local, remote, timeoutMs = timeoutMs)
Log.i(TAG, "push($serial): ${local.name} -> $remote exit=${result.exitCode} ${result.output.take(200)}")
if (result.ok) {
Result.success(result.output)
} else {
Result.failure(IllegalStateException(result.output.ifBlank { "exit=${result.exitCode}" }))
}
}
/**
* Диагностика: подключиться к заданному `ip:port` в обход Mercury/mDNS.
* Нужно, чтобы проверять adb-часть, когда телефон и очки в разных сетях (через релей).
*/
suspend fun ensureConnectedTo(hostPort: String): String? = withContext(Dispatchers.IO) {
if (!adb.available) {
fail("adb-бинарник или ключ не встроены в сборку")
return@withContext null
}
connectMutex.withLock {
_state.value = AdbConnectionState.BringingUp
adb.installKey()
adb.startServer(timeoutMs = 15_000)
if (!adb.isConnected(hostPort, timeoutMs = PROBE_TIMEOUT_MS)) {
adb.disconnect(hostPort, timeoutMs = 5_000)
adb.connect(hostPort, timeoutMs = 12_000)
}
if (!adb.isConnected(hostPort, timeoutMs = PROBE_TIMEOUT_MS)) {
fail("adb connect $hostPort не удался")
return@withLock null
}
address = hostPort
_state.value = AdbConnectionState.Connected(hostPort)
hostPort
}
}
/**
* Сбросить кэш соединения — вызывать при разрыве BLE/WiFi: адрес почти наверняка мёртв,
* а wireless-adb очки уже погасили сами.
*/
fun invalidate() {
Log.i(TAG, "invalidate(): забываем adb-соединение")
address = null
_state.value = AdbConnectionState.Disconnected
}
/** Полный подъём: wireless-adb через Mercury → адрес (Mercury/mDNS) → `adb connect`. */
private suspend fun bringUp(): String? {
_state.value = AdbConnectionState.BringingUp
adb.installKey()
adb.startServer(timeoutMs = 15_000)
// Mercury почти никогда не отдаёт адрес (flow не срабатывает), а mDNS находит за ~20мс —
// поэтому ждём Mercury недолго, чтобы UI не висел «включаем ADB over WiFi».
val found = mercury.enableWirelessAdbAndAwaitAddress(timeoutMs = MERCURY_ADDRESS_WAIT_MS)
?: discoverWirelessAdb(timeoutMs = MDNS_TIMEOUT_MS)
if (found == null) {
fail("не удалось получить адрес ADB over WiFi на очках")
return null
}
Log.i(TAG, "адрес wireless-adb: $found")
if (!adb.isConnected(found, timeoutMs = PROBE_TIMEOUT_MS)) {
// Возможен «already connected» на протухшую сессию (adb-сервер переживает
// перезапуск процесса) — рвём соединение и подключаемся заново.
adb.disconnect(found, timeoutMs = 5_000)
val connect = adb.connect(found, timeoutMs = 12_000)
Log.i(TAG, "adb connect $found -> ${connect.output}")
}
if (!adb.isConnected(found, timeoutMs = PROBE_TIMEOUT_MS)) {
fail("adb connect $found не удался")
return null
}
_state.value = AdbConnectionState.Connected(found)
Log.i(TAG, "adb подключён: $found")
return found
}
/**
* Fallback: если Mercury не отдал `ip:port`, ищем сервис `_adb-tls-connect._tcp`,
* который adbd очков публикует в mDNS при включённом wireless debugging.
*/
private suspend fun discoverWirelessAdb(timeoutMs: Long): String? =
withTimeoutOrNull(timeoutMs) {
suspendCancellableCoroutine<String?> { cont ->
val nsd = appContext.getSystemService(Context.NSD_SERVICE) as? NsdManager ?: run {
cont.resume(null); return@suspendCancellableCoroutine
}
val discovery = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
cont.resume(null)
}
override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) = Unit
override fun onDiscoveryStarted(serviceType: String?) = Unit
override fun onDiscoveryStopped(serviceType: String?) = Unit
override fun onServiceLost(serviceInfo: NsdServiceInfo?) = Unit
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
nsd.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo?, errorCode: Int) = Unit
override fun onServiceResolved(info: NsdServiceInfo) {
val host = info.host?.hostAddress ?: return
if (cont.isActive) cont.resume("$host:${info.port}")
}
})
}
}
nsd.discoverServices("_adb-tls-connect._tcp", NsdManager.PROTOCOL_DNS_SD, discovery)
cont.invokeOnCancellation {
runCatching { nsd.stopServiceDiscovery(discovery) }
}
}
}
private fun fail(message: String) {
Log.w(TAG, "adb: $message")
_state.value = AdbConnectionState.Failed(message)
}
private companion object {
const val TAG = "AdbController"
/** Быстрая проверка «жив ли serial» — короткий таймаут, чтобы не ждать мёртвое соединение. */
const val PROBE_TIMEOUT_MS = 4_000L
/** Сколько ждём адрес от Mercury, прежде чем искать по mDNS. */
const val MERCURY_ADDRESS_WAIT_MS = 5_000L
const val MDNS_TIMEOUT_MS = 8_000L
const val DEFAULT_SHELL_TIMEOUT_MS = 15_000L
const val DEFAULT_PUSH_TIMEOUT_MS = 60_000L
}
}
/** Состояние adb-соединения с очками (для UI). */
sealed interface AdbConnectionState {
data object Disconnected : AdbConnectionState
data object BringingUp : AdbConnectionState
data class Connected(val address: String) : AdbConnectionState
data class Failed(val message: String) : AdbConnectionState
val label: String
get() = when (this) {
Disconnected -> "нет соединения"
BringingUp -> "подключаемся…"
is Connected -> address
is Failed -> "ошибка: $message"
}
}
@@ -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,27 +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 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) {
}, // Оборачивание Int через переполнение даёт «огромный» gap — отсекаем.
onConnected = { Log.i(TAG, "audio connected to $wsUrl") }, val gap = seq - lastSeq - 1
onDisconnected = { Log.w(TAG, "audio disconnected") }, if (gap in 1 until MAX_GAP) {
lost += gap
// PLC: синтезируем заменители потерянных кадров, чтобы на месте
// пропуска не было резкого «щелчка» на стыке.
repeat(minOf(gap, MAX_CONCEAL)) {
val n = try {
decoder.decodeLost(pcmOut)
} catch (e: Throwable) {
-1
}
if (n > 0) t.write(pcmOut, 0, n * AudioConfig.CHANNELS)
}
}
}
lastSeq = seq
val decoded = try {
decoder.decode(
datagram,
AudioDatagram.HEADER_SIZE,
datagram.size - AudioDatagram.HEADER_SIZE,
pcmOut,
) )
} catch (e: Throwable) {
Log.w(TAG, "opus decode failed: ${e.message}")
-1
}
if (decoded > 0) {
t.write(pcmOut, 0, decoded * AudioConfig.CHANNELS)
}
frames++
if (frames % 200 == 0) {
Log.i(TAG, "playing: frames=$frames lost=$lost underruns=${t.underrunCount} lastSeq=$seq")
}
},
onConnected = {
Log.i(TAG, "audio udp connected to $glassesHost:${AudioConfig.UDP_PORT}")
// Подписка: заставляем очки запомнить наш адрес как аудио-пира.
runCatching { transport!!.send(subscribe) }
},
onDisconnected = { Log.w(TAG, "audio udp disconnected from $glassesHost") },
)
} catch (t: CancellationException) {
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
@@ -100,9 +180,13 @@ class AudioPlaybackManager(private val context: Context) {
Log.i(TAG, "AudioPlaybackManager stopped") Log.i(TAG, "AudioPlaybackManager stopped")
} }
companion object { private companion object {
private const val TAG = "AudioPlaybackMgr" const val TAG = "AudioPlaybackMgr"
const val SAMPLE_RATE = 44100
const val GLASSES_PORT = 8080 /** Пропуск seq больше этого — оборачивание Int или сбой, а не реальные потери. */
const val MAX_GAP = 100
/** Максимум кадров подряд «дотягиваем» PLC, чтобы не залипнуть на длинной потере. */
const val MAX_CONCEAL = 5
} }
} }
@@ -0,0 +1,138 @@
package pw.binom.rayneovm.phone.bootstrap
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.protocol.VmPhoneController
/**
* Гарантирует, что VmAccessibilityService на очках включён.
*
* Используется ровно тот же путь, что и [ShizukuProvisioner] (через
* [AdbController] → беспроводной adb → `settings put secure`). Если у
* `AdbController` нет соединения, мы НЕ поднимаем adb силой — это работа
* бутстрапа Shizuku; accessibility всё равно будет включен при следующем
* успешном коннекте.
*
* Вызывается на каждый BLE-коннект (см. [VmPhoneController] и
* `ShizukuProvisioner.ensure()`). Так ловим случаи `am force-stop` /
* обновления APK, которые сбрасывают accessibility-флаг.
*/
class AccessibilityProvisioner(
private val controller: VmPhoneController,
private val adb: AdbController,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
private val _state = MutableStateFlow<AccessibilityState>(AccessibilityState.Idle)
val state: StateFlow<AccessibilityState> = _state.asStateFlow()
/**
* Проверить, что VmAccessibilityService есть в `enabled_accessibility_services`.
* Если нет — добавить. Один раз за BLE-сессию.
*/
fun ensure() {
Log.i(TAG, "ensure(): состояние=${_state.value}")
if (mutex.isLocked) {
Log.i(TAG, "ensure(): уже идёт проверка")
return
}
scope.launch {
mutex.withLock { ensureLocked() }
}
}
/**
* BLE-сессия разорвана — при следующем коннекте снова проверим.
* (См. проверенное: `am force-stop` снимает accessibility-флаг,
* поэтому перепроверяем каждый раз.)
*/
fun onBleDisconnected() {
_state.value = AccessibilityState.Idle
}
private suspend fun ensureLocked() {
if (!adb.available) {
// Без adb мы ничего не сделаем — но состояние не сбрасываем, чтобы UI не моргал.
Log.i(TAG, "ensureLocked(): adb недоступен, пропуск")
return
}
// Шарим соединение с ShizukuProvisioner (если оно поднято).
val serial = adb.ensureConnected() ?: run {
Log.i(TAG, "ensureLocked(): нет adb-соединения")
return
}
Log.i(TAG, "ensureLocked(): adb=$serial, проверяю accessibility")
val current = readEnabledServices().getOrDefault("")
Log.i(TAG, "ensureLocked(): enabled_accessibility_services='$current'")
if (current.contains(SERVICE_NAME)) {
_state.value = AccessibilityState.Enabled
return
}
_state.value = AccessibilityState.Enabling
val append = if (current.isBlank()) SERVICE_FQDN else "$current:$SERVICE_FQDN"
val put = adb.shell(
"settings put secure enabled_accessibility_services '$append'",
timeoutMs = 15_000,
)
val enable = adb.shell(
"settings put secure accessibility_enabled 1",
timeoutMs = 15_000,
)
if (put.isSuccess && enable.isSuccess) {
Log.i(TAG, "ensureLocked(): accessibility включён")
_state.value = AccessibilityState.Enabled
} else {
val msg = "settings put failed: put=${put.isSuccess}, enable=${enable.isSuccess}"
Log.w(TAG, "ensureLocked(): $msg")
_state.value = AccessibilityState.Failed(msg)
}
}
private suspend fun readEnabledServices(): Result<String> = runCatching {
val out = adb.shell("settings get secure enabled_accessibility_services", timeoutMs = 15_000)
.getOrElse { error("shell failed: ${it.message}") }
// Если значение равно «null» — это дефолтное состояние, без включённых сервисов.
out.trim().takeIf { it.isNotEmpty() && it != "null" } ?: ""
}
fun close() {
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
}
private companion object {
const val TAG = "AccessibilityProvisioner"
const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
const val SERVICE_NAME = "VmAccessibilityService"
// settings put ожидает формат ComponentName через package/class (НЕ .FQCN, а /FQCN).
const val SERVICE_FQDN = "$GLASSES_PACKAGE/$GLASSES_PACKAGE.$SERVICE_NAME"
}
}
/** Состояние автовключения accessibility на очках (для UI). */
sealed interface AccessibilityState {
data object Idle : AccessibilityState
data object Enabling : AccessibilityState
data object Enabled : AccessibilityState
data class Failed(val message: String) : AccessibilityState
val label: String
get() = when (this) {
Idle -> "не проверялся"
Enabling -> "включаем…"
Enabled -> "включён"
is Failed -> "ошибка: $message"
}
}
@@ -0,0 +1,345 @@
package pw.binom.rayneovm.phone.bootstrap
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
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.AdbConnectionState
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import java.io.File
/**
* Автономный подъём Shizuku на очках с телефона.
*
* Сценарий: телефон подключился к очкам по Mercury BLE → если Shizuku на очках
* не запущен, просим очки включить «ADB over WiFi» → подключаемся к ним встроенным
* adb по wireless-debugging (TLS, ключ уже авторизован) → заливаем starter →
* запускаем `shizuku_server` → (опционально) просим Mercury запустить наше
* приложение. Всё без ПК и без ручных кнопок.
*
* Сам adb (подключение и команды) живёт в [AdbController] — здесь только домен Shizuku.
*/
class ShizukuProvisioner(
context: Context,
private val mercury: MercuryController,
private val controller: VmPhoneController,
private val adb: AdbController,
) {
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
private val _state = MutableStateFlow<ProvisionState>(ProvisionState.Idle)
val state: StateFlow<ProvisionState> = _state.asStateFlow()
/**
* true, если Shizuku уже поднимали в текущей BLE-сессии. Сбрасывается при разрыве BLE
* (см. [onBleDisconnected]) и при явном статусе «Shizuku не работает» с очков.
*
* Без этого флага полный цикл (wireless-adb + adb connect + appMarketLaunch) гонялся
* каждые 30с: очки не отдают ShizukuStatus, пока WiFi-канал не устоялся, и телефон
* бесконечно перезапускал GlassesApp — экран очков «мигал».
*/
@Volatile
private var provisionedInSession = false
/**
* Разовые системные разрешения на очках (RECORD_AUDIO, appop PROJECT_MEDIA) —
* выдаём один раз за сессию. Без них AudioPlaybackCapture (задача #2) не поднимется.
*/
@Volatile
private var grantsAppliedInSession = false
/**
* Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто:
* дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию.
*/
fun ensure() {
Log.i(
TAG,
"ensure(): вызов, состояние=${_state.value}, shizuku=${controller.glassesShizuku.value}, " +
"provisioned=$provisionedInSession",
)
maybeApplyGrants()
scope.launch {
val shizuku = controller.glassesShizuku.value
// Shizuku точно работает — ничего не делаем.
if (shizuku?.running == true) {
_state.value = ProvisionState.Running
provisionedInSession = true
return@launch
}
// Shizuku точно НЕ работает (очки прислали явный статус) — поднимаем заново,
// даже если уже поднимали в этой сессии.
if (shizuku?.running == false) {
provisionedInSession = false
}
// Статус неизвестен (WiFi-канал ещё не устоялся / GlassesApp не запущен), но
// Shizuku мы уже поднимали в этой BLE-сессии — не гоняем цикл повторно.
if (provisionedInSession && shizuku == null) {
Log.i(TAG, "ensure(): уже поднимали в этой сессии, статус неизвестен — пропуск")
return@launch
}
if (mutex.isLocked) {
Log.i(TAG, "ensure(): уже идёт бутстрап")
return@launch
}
mutex.withLock { provisionLocked() }
}
}
/**
* Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure():
* `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku.
*
* `PROJECT_MEDIA` ставится и для пакета, и для UID — иначе на свежих прошивках
* `MediaProjectionManager.createScreenCaptureIntent()` возвращает `RESULT_CANCELED`,
* даже если `cmd appops get <pkg> PROJECT_MEDIA` показывает `allow`. Проверено на
* RayNeo X3 Pro / Android 12 (см. BLOCKERS.md, B-3 follow-up).
*/
private fun maybeApplyGrants() {
if (grantsAppliedInSession) return
scope.launch {
val serial = adb.ensureConnected() ?: return@launch
Log.i(TAG, "применяю системные гранты на очках ($serial)")
val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000)
val appopPkg = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000)
// PROJECT_MEDIA на Android 12+ привязан к UID, не к пакету: даже если per-package
// стоит allow, диалог проекции сразу RESULT_CANCELED пока per-UID = ignore.
val uidOut = adb.shell("cmd package list packages -U $GLASSES_PACKAGE", timeoutMs = 15_000).getOrNull()
val uid = uidOut?.lineSequence()
?.mapNotNull { Regex("""uid:(\d+)""").find(it)?.groupValues?.get(1)?.toIntOrNull() }
?.firstOrNull()
val appopUid = if (uid != null) {
adb.shell("cmd appops set --uid $uid PROJECT_MEDIA allow", timeoutMs = 15_000)
} else {
Log.w(TAG, "не удалось узнать UID $GLASSES_PACKAGE — PROJECT_MEDIA per-UID не выставлен")
Result.failure(IllegalStateException("uid not found"))
}
// #4.1: глушим системную клавиатуру гостя. Наш VmNoopImeService дефолтом стать
// не может — вендорский ROM жёстко держит LatinIME (allowlist; даже системный
// Sogou откатывается). Поэтому просто отключаем LatinIME: на очках не поднимается
// ни один IME, а текст с телефона доставляется через AccessibilityService
// (ACTION_SET_TEXT, см. VmAccessibilityService). На «обычных» ROM сработает и
// первый способ — назначение нашего IME.
val ime = adb.shell(
"ime enable $GLASSES_PACKAGE/.VmNoopImeService; " +
"ime set $GLASSES_PACKAGE/.VmNoopImeService; " +
"pm disable-user --user 0 com.android.inputmethod.latin",
timeoutMs = 15_000,
)
Log.i(
TAG,
"гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA[pkg]=${appopPkg.isSuccess}" +
"[uid=$uid]=${appopUid.isSuccess}, IME=${ime.isSuccess}",
)
if (grant.isSuccess && appopPkg.isSuccess && appopUid.isSuccess && ime.isSuccess) {
grantsAppliedInSession = true
}
}
}
/** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */
fun onBleDisconnected() {
provisionedInSession = false
grantsAppliedInSession = false
// wireless-adb очки гасят сами при потере сети — соединение считать мёртвым.
adb.invalidate()
}
/** Принудительно прогнать весь сценарий (диагностика/кнопка). */
fun force() {
Log.i(TAG, "force(): ручной запуск бутстрапа")
scope.launch {
if (mutex.isLocked) return@launch
mutex.withLock { provisionLocked() }
}
}
/**
* Диагностика: прогнать сценарий с заданным адресом `ip:port` (в обход Mercury/mDNS).
* Нужно, чтобы проверить adb-часть, когда телефон и очки в разных сетях (через релей).
*/
fun forceWithAddress(address: String) {
Log.i(TAG, "forceWithAddress($address)")
scope.launch {
if (mutex.isLocked) return@launch
mutex.withLock { provisionLocked(addressOverride = address) }
}
}
private suspend fun provisionLocked(addressOverride: String? = null) {
Log.i(TAG, "provisionLocked(): старт, adb.available=${adb.available}, override=$addressOverride")
if (!adb.available) {
fail("adb-бинарник или ключ не встроены в сборку")
return
}
var lastError: String? = null
for (attempt in 1..MAX_ATTEMPTS) {
Log.i(TAG, "provisionLocked(): попытка $attempt/$MAX_ATTEMPTS")
val error = tryProvision(addressOverride)
if (error == null) {
provisionedInSession = true
return
}
lastError = error
_state.value = ProvisionState.Failed(error)
if (attempt < MAX_ATTEMPTS) delay(RETRY_DELAY_MS)
}
fail(lastError ?: "неизвестная ошибка бутстрапа")
}
/** Одна попытка. `null` — успех (Shizuku поднят), иначе текст ошибки. */
private suspend fun tryProvision(addressOverride: String? = null): String? {
return try {
_state.value = ProvisionState.Checking
_state.value = ProvisionState.EnablingAdb
val serial = if (addressOverride != null) {
adb.ensureConnectedTo(addressOverride)
} else {
adb.ensureConnected()
} ?: return (adb.state.value as? AdbConnectionState.Failed)?.message
?: "нет adb-соединения с очками"
_state.value = ProvisionState.Connecting(serial)
if (isShizukuRunning()) {
Log.i(TAG, "Shizuku уже запущен на очках")
_state.value = ProvisionState.Running
launchGlassesAppIfNeeded()
return null
}
_state.value = ProvisionState.StartingShizuku
val starter = copyStarterToCache()
Log.i(TAG, "push starter ${starter.length()}b -> $REMOTE_STARTER")
val push = adb.push(starter, REMOTE_STARTER)
// push может «провалиться» из-за уже существующего файла — проверяем наличие.
if (push.isFailure && adb.shell("test -f $REMOTE_STARTER").isFailure) {
return "push starter не удался: ${push.exceptionOrNull()?.message}"
}
adb.shell("chmod 755 $REMOTE_STARTER")
val apkPath = shizukuApkPath()
Log.i(TAG, "shizuku apk: $apkPath")
val args = buildString {
append(REMOTE_STARTER)
if (apkPath != null) append(" --apk=").append(apkPath)
}
val run = adb.shell(args, timeoutMs = 90_000)
Log.i(TAG, "starter -> ${run.getOrNull() ?: "ошибка: ${run.exceptionOrNull()?.message}"}")
delay(START_VERIFY_DELAY_MS)
if (isShizukuRunning()) {
Log.i(TAG, "Shizuku поднят")
_state.value = ProvisionState.Running
launchGlassesAppIfNeeded()
null
} else {
"стартер не поднял shizuku_server: ${run.exceptionOrNull()?.message?.take(300).orEmpty()}"
}
} catch (t: Throwable) {
Log.w(TAG, "tryProvision(): исключение", t)
"ошибка бутстрапа: ${t.message}"
}
}
private suspend fun isShizukuRunning(): Boolean {
val pidof = adb.shell("pidof shizuku_server", timeoutMs = 15_000).getOrDefault("")
Log.i(TAG, "pidof shizuku_server -> '$pidof'")
return pidof.isNotBlank()
}
private suspend fun shizukuApkPath(): String? =
adb.shell("pm path $SHIZUKU_PACKAGE", timeoutMs = 15_000)
.getOrNull()
?.lineSequence()
?.map { it.removePrefix("package:").trim() }
?.firstOrNull { it.endsWith(".apk") }
private fun copyStarterToCache(): File {
val out = File(appContext.cacheDir, "shizuku_starter")
appContext.assets.open("shizuku/starter").use { input ->
out.outputStream().use { input.copyTo(it) }
}
out.setExecutable(true, true)
return out
}
private fun launchGlassesApp() {
scope.launch {
try {
val ok = mercury.launchGlassesApp(GLASSES_PACKAGE)
Log.i(TAG, "appMarketLaunch($GLASSES_PACKAGE) -> $ok")
} catch (t: Throwable) {
Log.w(TAG, "appMarketLaunch failed: ${t.message}")
}
}
}
/**
* Запускаем GlassesApp через Mercury только если WiFi-канал ещё НЕ подключён.
* Если канал жив — приложение на очках уже работает, а повторный appMarketLaunch
* лишь перезапускает Activity (экран мигает).
*/
private fun launchGlassesAppIfNeeded() {
if (controller.state.value is ConnectionState.Connected) {
Log.i(TAG, "WiFi-канал уже подключён — GlassesApp не перезапускаем")
return
}
launchGlassesApp()
}
private fun fail(message: String) {
Log.w(TAG, "Shizuku bootstrap: $message")
_state.value = ProvisionState.Failed(message)
}
fun close() {
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
}
private companion object {
const val TAG = "ShizukuProvisioner"
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
const val REMOTE_STARTER = "/data/local/tmp/shizuku_starter"
const val START_VERIFY_DELAY_MS = 4_000L
const val MAX_ATTEMPTS = 3
const val RETRY_DELAY_MS = 5_000L
}
}
/** Состояние автономного бутстрапа Shizuku (для UI). */
sealed interface ProvisionState {
data object Idle : ProvisionState
data object Checking : ProvisionState
data object EnablingAdb : ProvisionState
data class Connecting(val address: String) : ProvisionState
data object StartingShizuku : ProvisionState
data object Running : ProvisionState
data class Failed(val message: String) : ProvisionState
val label: String
get() = when (this) {
Idle -> "не запускался"
Checking -> "проверяем Shizuku…"
EnablingAdb -> "включаем ADB over WiFi…"
is Connecting -> "подключаемся к $address…"
StartingShizuku -> "запускаем Shizuku…"
Running -> "Shizuku поднят"
is Failed -> "ошибка: $message"
}
}
@@ -0,0 +1,96 @@
package pw.binom.rayneovm.phone.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.AdbControllerProvider
import pw.binom.rayneovm.phone.ShizukuProvisionerProvider
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
import pw.binom.rayneovm.phone.protocol.ControllerProvider
import pw.binom.rayneovm.shared.AudioMode
/**
* Диагностический приёмник (shell/system, `android.permission.DUMP`):
*
* - `-a pw.binom.rayneovm.phone.DEBUG_PROVISION` — принудительный подъём Shizuku
* (`--es address ip:port` — с заданным адресом, в обход Mercury/mDNS);
* - `-a pw.binom.rayneovm.phone.DEBUG_SET_BRIGHTNESS --ei value 42` — выставить яркость
* очков через Mercury BLE (проверка слайдера без тыканья в UI);
* - `-a pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS` — перезапросить статус очков;
* - `-a pw.binom.rayneovm.phone.DEBUG_ADB_SHELL --es cmd '...'` — adb-shell команда на очки
* через [pw.binom.rayneovm.phone.adb.AdbController] (сам поднимает wireless-adb).
*/
class DebugReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
val app = context.applicationContext
when (intent.action) {
ACTION_SET_BRIGHTNESS -> {
val mc = (app as? MercuryControllerProvider)?.provideMercuryController() ?: return
val value = intent.getIntExtra("value", -1)
Log.i(TAG, "DEBUG_SET_BRIGHTNESS value=$value")
mc.setBrightness(value)
}
ACTION_SET_DEEP_SUSPEND -> {
val mc = (app as? MercuryControllerProvider)?.provideMercuryController() ?: return
val sleep = intent.getIntExtra("on", 0) != 0
Log.i(TAG, "DEBUG_SET_DEEP_SUSPEND on=$sleep")
mc.setDeepSuspendAsync(sleep)
}
ACTION_REFRESH_STATUS -> {
val mc = (app as? MercuryControllerProvider)?.provideMercuryController() ?: return
Log.i(TAG, "DEBUG_REFRESH_STATUS")
mc.refreshStatus()
}
ACTION_ADB_SHELL -> {
val adb = (app as? AdbControllerProvider)?.provideAdbController() ?: return
val cmd = intent.getStringExtra("cmd")
if (cmd.isNullOrBlank()) {
Log.w(TAG, "DEBUG_ADB_SHELL без --es cmd")
return
}
Log.i(TAG, "DEBUG_ADB_SHELL: $cmd")
scope.launch {
val result = adb.shell(cmd)
Log.i(TAG, "DEBUG_ADB_SHELL результат: ${result.getOrNull() ?: result.exceptionOrNull()}")
}
}
ACTION_SET_AUDIO -> {
val controller = (app as? ControllerProvider)?.provideController() ?: return
val mode = if (intent.getIntExtra("mode", 0) != 0) AudioMode.ON_PHONE else AudioMode.ON_GLASSES
Log.i(TAG, "DEBUG_SET_AUDIO mode=$mode")
scope.launch { controller.setAudioMode(mode) }
}
else -> {
val provider = app as? ShizukuProvisionerProvider ?: return
Log.i(TAG, "получен ${intent.action} — запускаю бутстрап Shizuku")
val address = intent.getStringExtra("address")
if (address.isNullOrBlank()) {
provider.provideShizukuProvisioner().force()
} else {
provider.provideShizukuProvisioner().forceWithAddress(address)
}
}
}
}
private companion object {
const val TAG = "DebugReceiver"
const val ACTION_SET_BRIGHTNESS = "pw.binom.rayneovm.phone.DEBUG_SET_BRIGHTNESS"
const val ACTION_SET_DEEP_SUSPEND = "pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND"
const val ACTION_REFRESH_STATUS = "pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS"
const val ACTION_ADB_SHELL = "pw.binom.rayneovm.phone.DEBUG_ADB_SHELL"
const val ACTION_SET_AUDIO = "pw.binom.rayneovm.phone.DEBUG_SET_AUDIO"
}
}
@@ -12,12 +12,19 @@ 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.cancel
import kotlinx.coroutines.delay
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
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import pw.binom.mercury.DiscoveredGlasses import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury import pw.binom.mercury.RayNeoMercury
import pw.binom.mercury.model.GeneralStatus
import pw.binom.mercury.model.MyWifiInfo
/** /**
* Контроллер второго канала связи с очками — RayNeo Mercury BLE. * Контроллер второго канала связи с очками — RayNeo Mercury BLE.
@@ -34,12 +41,39 @@ class MercuryController(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mercury = RayNeoMercury(context, externalScope = scope) private val mercury = RayNeoMercury(context, externalScope = scope)
private val prefs = context.applicationContext.getSharedPreferences("mercury", Context.MODE_PRIVATE)
private val _state = MutableStateFlow<MercuryState>(MercuryState.PermissionsMissing) private val _state = MutableStateFlow<MercuryState>(MercuryState.PermissionsMissing)
val state: StateFlow<MercuryState> = _state.asStateFlow() val state: StateFlow<MercuryState> = _state.asStateFlow()
private var lastScanResults: List<DiscoveredGlasses> = emptyList() private var lastScanResults: List<DiscoveredGlasses> = emptyList()
private val _generalStatus = MutableStateFlow<GeneralStatus?>(null)
/** Последний известный статус очков: яркость, WiFi, экран, батарея. */
val generalStatus: StateFlow<GeneralStatus?> = _generalStatus.asStateFlow()
/** Цикл опроса статуса (живёт, пока держится BLE-сессия). */
private var statusRefreshJob: Job? = null
private val _wifiNetworks = MutableStateFlow<List<MyWifiInfo>>(emptyList())
/** Сети, найденные последним сканом WiFi на очках. */
val wifiNetworks: StateFlow<List<MyWifiInfo>> = _wifiNetworks.asStateFlow()
private val _wifiScanning = MutableStateFlow(false)
/** Идёт ли сейчас скан WiFi-сетей очков. */
val wifiScanning: StateFlow<Boolean> = _wifiScanning.asStateFlow()
/** Адрес «ADB over WiFi» на очках (`ip:port`), полученный после [enableWirelessAdb]. */
private val _wirelessAdbAddress = MutableStateFlow<String?>(null)
val wirelessAdbAddress: StateFlow<String?> = _wirelessAdbAddress.asStateFlow()
/** Идёт ли ожидание адреса ADB over WiFi. */
private val _wirelessAdbEnabling = MutableStateFlow(false)
val wirelessAdbEnabling: StateFlow<Boolean> = _wirelessAdbEnabling.asStateFlow()
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */ /** Разрешения, которые нужно запросить для работы с BLE на этом API. */
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf( arrayOf(
@@ -53,6 +87,8 @@ class MercuryController(private val context: Context) {
init { init {
observeEvents() observeEvents()
refreshPermissionState() refreshPermissionState()
// Автопоиск/автоподключение BLE без кнопок: как только очки доступны — соединяемся.
scope.launch { autoConnectLoop() }
} }
/** Перепроверить состояние Bluetooth-адаптера и разрешений. */ /** Перепроверить состояние Bluetooth-адаптера и разрешений. */
@@ -129,36 +165,165 @@ class MercuryController(private val context: Context) {
return return
} }
_state.value = MercuryState.Connecting(glasses) _state.value = MercuryState.Connecting(glasses)
scope.launch { scope.launch { connectInternal(glasses) }
}
/** Общая логика подключения: пара (если надо) + GATT. Сохраняет адрес для автоподключения. */
private suspend fun connectInternal(glasses: DiscoveredGlasses) {
try { try {
val paired = mercury.pair(glasses) val paired = mercury.pair(glasses)
if (paired is pw.binom.mercury.pair.PairManager.PairingResult.Failed) { if (paired is pw.binom.mercury.pair.PairManager.PairingResult.Failed) {
_state.value = MercuryState.Disconnected( _state.value = MercuryState.Disconnected(
message = "Сопряжение не удалось: ${paired.reason}", message = "Сопряжение не удалось: ${paired.reason}",
) )
return@launch return
} }
val ok = mercury.connect(glasses) val ok = mercury.connect(glasses)
if (!ok) { if (!ok) {
_state.value = MercuryState.Disconnected( _state.value = MercuryState.Disconnected(
message = "Не удалось открыть GATT-сессию", message = "Не удалось открыть GATT-сессию",
) )
return@launch return
} }
saveAddress(glasses)
_state.value = MercuryState.Connected(glasses, batteryPercent = null) _state.value = MercuryState.Connected(glasses, batteryPercent = null)
// Запрашиваем актуальный статус, чтобы получить battery/wifi syncClock()
mercury.pullGeneralStatus() startStatusRefresh()
} catch (t: Throwable) { } catch (t: Throwable) {
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}") _state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
} }
} }
/**
* Автопоиск и автоподключение BLE — без кнопок. Сначала пробуем известный адрес
* (мгновенно, без скана), иначе сканируем. Повторяем, пока не соединимся.
*/
private suspend fun autoConnectLoop() {
while (scope.isActive) {
val st = _state.value
if (st is MercuryState.Connected || st is MercuryState.Connecting) {
delay(AUTO_IDLE_STEP_MS)
continue
}
if (!hasPermissions()) {
_state.value = MercuryState.PermissionsMissing
delay(AUTO_IDLE_STEP_MS)
continue
}
if (!isBluetoothEnabled()) {
_state.value = MercuryState.BluetoothOff
delay(AUTO_IDLE_STEP_MS)
continue
}
tryAutoConnect()
delay(AUTO_RETRY_MS)
}
}
private suspend fun tryAutoConnect() {
// 1) Известный адрес — подключаемся напрямую, без скана.
savedAddress()?.let { address ->
val device = remoteDevice(address)
if (device != null) {
android.util.Log.i(TAG, "автоподключение по известному адресу $address")
val glasses = DiscoveredGlasses(device, name = savedName())
_state.value = MercuryState.Connecting(glasses)
connectInternal(glasses)
if (_state.value is MercuryState.Connected) return
}
}
// 2) Скан — на случай смены адреса или первого подключения.
android.util.Log.i(TAG, "автопоиск: скан очков…")
_state.value = MercuryState.Scanning(emptyList())
val found = mercury.scanOnce(SCAN_TIMEOUT_MS)
if (found != null) {
_state.value = MercuryState.Connecting(found)
connectInternal(found)
if (_state.value is MercuryState.Connected) return
}
_state.value = MercuryState.Disconnected(message = "Очки не найдены — продолжаю поиск…")
}
private fun savedAddress(): String? = prefs.getString(KEY_ADDRESS, null)
private fun savedName(): String? = prefs.getString(KEY_NAME, null)
private fun saveAddress(glasses: DiscoveredGlasses) {
prefs.edit()
.putString(KEY_ADDRESS, glasses.address)
.putString(KEY_NAME, glasses.name ?: glasses.device.name)
.apply()
}
@Suppress("DEPRECATION")
private fun remoteDevice(address: String): android.bluetooth.BluetoothDevice? = try {
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
?.adapter
?.getRemoteDevice(address)
} catch (t: Throwable) {
null
} }
fun disconnect() { fun disconnect() {
scope.launch { mercury.disconnect() } scope.launch { mercury.disconnect() }
clearGlassesData()
_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`
* при изменениях, а ответ на первый запрос легко теряется (BLE-нотификация
* приходит раньше, чем сессия полностью встала). Поэтому перезапрашиваем,
* пока не прилетит первый статус, дальше — редкий фоновый рефреш как страховка
* от потерянных нотификаций.
*/
private fun startStatusRefresh() {
statusRefreshJob?.cancel()
statusRefreshJob = scope.launch {
var attempt = 0
while (isActive && _state.value is MercuryState.Connected && _generalStatus.value == null) {
if (attempt > 0) delay(STATUS_RETRY_STEP_MS * attempt)
attempt++
pullStatus("initial#$attempt")
}
while (isActive && _state.value is MercuryState.Connected) {
delay(STATUS_REFRESH_MS)
pullStatus("refresh")
}
}
}
/** Разовый запрос статуса (например, при открытии вкладки «Очки»). */
fun refreshStatus() {
scope.launch { pullStatus("manual") }
}
private suspend fun pullStatus(tag: String) {
try {
val ok = mercury.pullGeneralStatus()
android.util.Log.d(TAG, "pullGeneralStatus($tag) -> $ok")
} catch (t: Throwable) {
android.util.Log.w(TAG, "pullGeneralStatus($tag) failed: ${t.message}")
}
}
/** /**
* Запустить приложение на очках через штатный механизм Mercury * Запустить приложение на очках через штатный механизм Mercury
* (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false. * (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false.
@@ -171,6 +336,149 @@ class MercuryController(private val context: Context) {
/** Доступ к raw event-flow библиотеки (debug/диагностика). */ /** Доступ к raw event-flow библиотеки (debug/диагностика). */
val events get() = mercury.events val events get() = mercury.events
// ------------------------------------------------------------------ экран / яркость / wifi
/**
* Яркость экрана очков, 0..100.
*
* При включённой авто-яркости очки отбрасывают явное значение, поэтому сначала
* выключаем авто (как в view-mate — иначе слайдер «не работает»).
*/
fun setBrightness(value: Int) = launchMercury("setBrightness") {
mercury.setAutoBrightness(false)
mercury.setBrightness(value)
}
/** Авто-яркость экрана очков. */
fun setAutoBrightness(enable: Boolean) = launchMercury("setAutoBrightness") {
mercury.setAutoBrightness(enable)
}
/**
* Погасить ([enable] = true) / разбудить экран очков по BLE (deepSuspend).
*
* Suspend-вариант с реальным результатом — кнопка в UI показывает ошибку, если
* команда не ушла (раньше «не работало» из-за баги hasStatus, см. library).
*/
suspend fun setDeepSuspend(enable: Boolean): Boolean = try {
mercury.setDeepSuspend(enable).also {
android.util.Log.i(TAG, "setDeepSuspend($enable) -> $it")
}
} catch (t: Throwable) {
android.util.Log.w(TAG, "setDeepSuspend($enable) failed: ${t.message}")
false
}
/** Fire-and-forget вариант [setDeepSuspend] (диагностика/DebugReceiver). */
fun setDeepSuspendAsync(enable: Boolean) = launchMercury("setDeepSuspend($enable)") {
mercury.setDeepSuspend(enable)
}
/** Включить/выключить WiFi-радио очков. */
fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") {
mercury.wifiToggle(enable)
}
/** Просканировать WiFi-сети очками; результат придёт в [wifiNetworks]. */
fun scanWifi() {
_wifiNetworks.value = emptyList()
_wifiScanning.value = true
scope.launch {
val ok = try {
mercury.wifiScan()
} catch (t: Throwable) {
android.util.Log.w(TAG, "wifiScan failed: ${t.message}")
false
}
if (!ok) _wifiScanning.value = false
}
// Очки не всегда присылают ответ — снимаем «сканируем…» по таймауту.
scope.launch {
delay(WIFI_SCAN_TIMEOUT_MS)
_wifiScanning.value = false
}
}
/** Подключить очки к WiFi-сети ([password] = null для открытой). */
fun connectWifi(ssid: String, password: String?) = launchMercury("connectWifi($ssid)") {
mercury.wifiConnect(ssid, password)
}
/**
* Просит очки включить «ADB over WiFi». Адрес вернётся событием и попадёт
* в [wirelessAdbAddress]. Нужен для ADB-бутстрапа Shizuku с телефона.
*/
fun enableWirelessAdb() {
_wirelessAdbAddress.value = null
_wirelessAdbEnabling.value = true
launchMercury("enableWirelessAdb") { mercury.enableWirelessAdb() }
// Очки не всегда отвечают адресом — снимаем «включаем…» по таймауту.
scope.launch {
delay(WIRELESS_ADB_TIMEOUT_MS)
_wirelessAdbEnabling.value = false
}
}
/** Просит очки выключить «ADB over WiFi». */
fun disableWirelessAdb() = launchMercury("disableWirelessAdb") {
mercury.disableWirelessAdb()
}
/**
* Включает «ADB over WiFi» на очках и дожидается адреса `ip:port`
* (или возвращает null, если очки не прислали его за [timeoutMs]).
*/
suspend fun enableWirelessAdbAndAwaitAddress(timeoutMs: Long = WIRELESS_ADB_WAIT_MS): String? {
_wirelessAdbAddress.value = null
_wirelessAdbEnabling.value = true
val sent = try {
mercury.enableWirelessAdb()
} catch (t: Throwable) {
android.util.Log.w(TAG, "enableWirelessAdb failed: ${t.message}")
false
}
if (!sent) {
_wirelessAdbEnabling.value = false
return null
}
return try {
withTimeoutOrNull(timeoutMs) { _wirelessAdbAddress.filterNotNull().first() }
} finally {
_wirelessAdbEnabling.value = false
}
}
/** Забыть сохранённую сеть на очках. */
fun forgetWifi(ssid: String) = launchMercury("forgetWifi($ssid)") {
mercury.wifiRemove(ssid)
}
private fun launchMercury(action: String, block: suspend () -> Any?) {
scope.launch {
val result = try {
block()
} catch (t: Throwable) {
android.util.Log.w(TAG, "$action failed: ${t.message}")
"исключение"
}
// Логируем результат: библиотека молча возвращает false, если статус ещё не
// получен (тогда команда никуда не уходит — «слайдер не работает»).
android.util.Log.i(TAG, "$action -> $result")
}
}
/** Сбрасывает данные, которые живут только внутри BLE-сессии. */
private fun clearGlassesData() {
android.util.Log.i(TAG, "clearGlassesData(): данные BLE-сессии сброшены (статус=null)")
statusRefreshJob?.cancel()
statusRefreshJob = null
_generalStatus.value = null
_wifiNetworks.value = emptyList()
_wifiScanning.value = false
_wirelessAdbAddress.value = null
_wirelessAdbEnabling.value = false
}
private fun refreshPermissionState() { private fun refreshPermissionState() {
_state.value = when { _state.value = when {
!hasPermissions() -> MercuryState.PermissionsMissing !hasPermissions() -> MercuryState.PermissionsMissing
@@ -197,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)
@@ -205,12 +516,20 @@ class MercuryController(private val context: Context) {
is RayNeoMercury.Event.Disconnected -> { is RayNeoMercury.Event.Disconnected -> {
val cur = _state.value val cur = _state.value
if (cur is MercuryState.Connected) { if (cur is MercuryState.Connected) {
clearGlassesData()
_state.value = MercuryState.Disconnected( _state.value = MercuryState.Disconnected(
message = "Связь потеряна: ${event.cause}", message = "Связь потеряна: ${event.cause}",
) )
} }
} }
is RayNeoMercury.Event.GeneralStatusUpdate -> { is RayNeoMercury.Event.GeneralStatusUpdate -> {
android.util.Log.i(
TAG,
"статус очков: brightness=${event.status.brightness} " +
"autoBright=${event.status.automaticBrightness} " +
"wifi=${event.status.wifiEnabled} battery=${event.status.battery}",
)
_generalStatus.value = event.status
val cur = _state.value val cur = _state.value
if (cur is MercuryState.Connected) { if (cur is MercuryState.Connected) {
_state.value = cur.copy( _state.value = cur.copy(
@@ -218,6 +537,15 @@ class MercuryController(private val context: Context) {
) )
} }
} }
is RayNeoMercury.Event.WifiScanResult -> {
_wifiNetworks.value = event.networks
_wifiScanning.value = false
}
is RayNeoMercury.Event.WirelessAdbAddress -> {
android.util.Log.i(TAG, "WirelessAdbAddress: ${event.address}")
_wirelessAdbAddress.value = event.address
_wirelessAdbEnabling.value = false
}
else -> Unit else -> Unit
} }
} }
@@ -230,6 +558,35 @@ class MercuryController(private val context: Context) {
} }
companion object { companion object {
private const val TAG = "MercuryController"
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
private const val WIFI_SCAN_TIMEOUT_MS = 15_000L
/** Сколько ждать адрес ADB-over-WiFi, прежде чем снять статус «включаем…». */
private const val WIRELESS_ADB_TIMEOUT_MS = 15_000L
/** Сколько ждать адрес ADB-over-WiFi при автоматическом бутстрапе. */
private const val WIRELESS_ADB_WAIT_MS = 25_000L
/** Пауза между повторами первичного запроса статуса (растёт линейно). */
private const val STATUS_RETRY_STEP_MS = 1_000L
/** Период фонового обновления статуса, когда он уже получен. */
private const val STATUS_REFRESH_MS = 15_000L
/** Пауза автопоиска BLE после неудачной попытки. */
private const val AUTO_RETRY_MS = 6_000L
/** Пауза цикла, когда уже подключены или ждём разрешений/Bluetooth. */
private const val AUTO_IDLE_STEP_MS = 3_000L
/** Таймаут однократного BLE-скана в автопоиске. */
private const val SCAN_TIMEOUT_MS = 8_000L
private const val KEY_ADDRESS = "glasses_address"
private const val KEY_NAME = "glasses_name"
val Local = staticCompositionLocalOf<MercuryController> { val Local = staticCompositionLocalOf<MercuryController> {
error("MercuryController not provided") error("MercuryController not provided")
} }
@@ -18,10 +18,20 @@ import pw.binom.rayneovm.shared.AppCrashedEvent
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.AppLaunchedAck import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.GetAppList import pw.binom.rayneovm.shared.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.Pong import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.SetText
import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.VmMessage import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol import pw.binom.rayneovm.shared.VmProtocol
@@ -43,9 +53,21 @@ class VmPhoneController(
private val _glassesStatus = MutableStateFlow<GlassesStatusInfo?>(null) private val _glassesStatus = MutableStateFlow<GlassesStatusInfo?>(null)
val glassesStatus: StateFlow<GlassesStatusInfo?> = _glassesStatus.asStateFlow() val glassesStatus: StateFlow<GlassesStatusInfo?> = _glassesStatus.asStateFlow()
/** Состояние Shizuku на очках (приходит с очков после подключения/по запросу). */
private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null)
val glassesShizuku: StateFlow<ShizukuStatus?> = _glassesShizuku.asStateFlow()
/** Реальное состояние экрана очков (шлют очки); null — пока неизвестно. */
private val _screenOn = MutableStateFlow<Boolean?>(null)
val screenOn: StateFlow<Boolean?> = _screenOn.asStateFlow()
private var currentTransport: StringTransport? = null private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) } private val audioManager by lazy { AudioPlaybackManager(context) }
/** Текущий аудио-режим (зеркалит то, что отправлено очкам) — для UI-переключателя. */
private val _audioMode = MutableStateFlow(pw.binom.rayneovm.shared.AudioMode.ON_GLASSES)
val audioMode: StateFlow<pw.binom.rayneovm.shared.AudioMode> = _audioMode.asStateFlow()
fun start() { fun start() {
if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return
scope.launch { reconnectLoop() } scope.launch { reconnectLoop() }
@@ -72,13 +94,13 @@ class VmPhoneController(
_state.value = ConnectionState.Searching _state.value = ConnectionState.Searching
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp") val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с. // mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
// Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT). // Порт 8080 и путь /vm (см. VmService.WS_PORT/WS_PATH). Путь нужен фоллбеку,
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080) // чтобы отличить наш сервер от чужого на том же порту.
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080, fallbackPath = "/vm")
_state.value = ConnectionState.Connecting(service.host) _state.value = ConnectionState.Connecting(service.host)
val url = "ws://${service.host}:${service.port}/vm" val url = "ws://${service.host}:${service.port}/vm"
val transport = Transports.wifi(url, autoReconnect = false) val transport = Transports.wifi(url, autoReconnect = false)
currentTransport = transport currentTransport = transport
_state.value = ConnectionState.Connected(service.host)
attempt = 0 attempt = 0
transport.connect( transport.connect(
onMessage = { json -> onMessage = { json ->
@@ -87,9 +109,27 @@ class VmPhoneController(
} catch (_: Throwable) { return@connect } } catch (_: Throwable) { return@connect }
handleIncoming(msg) handleIncoming(msg)
}, },
onConnected = { /* уже выставлено в Connecting->Connected */ }, onConnected = {
// Только здесь канал реально поднят (рукопожатие прошло). Раньше
// Connected выставлялся сразу после mDNS — из-за этого триггер
// бутстрапа Shizuku срабатывал на «мёртвом» канале.
_state.value = ConnectionState.Connected(service.host)
// Очки сами присылают ShizukuStatus при подключении; на всякий
// случай дублируем запросом — первый ответ легко теряется.
scope.launch { send(RequestShizukuStatus) }
// Канал поднялся: если выбран вывод звука на телефон — восстанавливаем
// его (после реконнекта очков захват на той стороне уже остановлен).
if (_audioMode.value == pw.binom.rayneovm.shared.AudioMode.ON_PHONE) {
scope.launch { send(SetAudioMode(pw.binom.rayneovm.shared.AudioMode.ON_PHONE)) }
audioManager.start(service.host)
}
},
onDisconnected = { onDisconnected = {
currentTransport = null currentTransport = null
_glassesShizuku.value = null
_screenOn.value = null
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
audioManager.stop()
_state.value = ConnectionState.Disconnected _state.value = ConnectionState.Disconnected
}, },
) )
@@ -122,6 +162,12 @@ class VmPhoneController(
is AppListResponse -> { is AppListResponse -> {
_appsOnGlasses.value = msg.apps _appsOnGlasses.value = msg.apps
} }
is ShizukuStatus -> {
_glassesShizuku.value = msg
}
is ScreenState -> {
_screenOn.value = msg.on
}
is SetAudioMode -> { is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую. // Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается. // AudioCaptureService на очках стартует/останавливается.
@@ -149,6 +195,19 @@ class VmPhoneController(
send(GetAppList) send(GetAppList)
} }
/** Перезапрашивает состояние Shizuku на очках (см. [glassesShizuku]). */
suspend fun 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
@@ -159,21 +218,69 @@ class VmPhoneController(
} }
} }
/** Позиция курсора мыши в координатах VirtualDisplay — очки рисуют его в обоих глазах. */
suspend fun moveCursor(x: Float, y: Float) = send(CursorMove(x, y))
/** Клик мыши в текущей позиции курсора на очках. */
suspend fun clickCursor() = send(CursorClick)
/** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */
suspend fun scrollCursor(dx: Float, dy: Float) = send(CursorScroll(dx, dy))
/**
* Погасить ([sleep] = true) / разбудить экран очков их же Shizuku-сервисом (Канал 1).
*
* @return false, если канал не подключён — вызывающий покажет ошибку, а не «0 реакции»
*/
suspend fun setScreen(sleep: Boolean): Boolean {
if (_state.value !is ConnectionState.Connected) return false
send(ScreenControl(sleep))
// Оптимистично: точное состояние очки пришлют сами (ScreenState) — возможно с задержкой.
_screenOn.value = !sleep
return true
}
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
suspend fun sendKey(keyCode: Int) {
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_UP))
}
/**
* Инжектит [text] в активное поле ввода гостя на дисплее [displayId] очков.
*
* Работает по [SetText]: очки получают команду, через Shizuku-binder достают InputConnection
* сфокусированного окна на нашем VirtualDisplay и делают `commitText`. Unicode-корректно
* (русский/эмодзи).
*/
suspend fun sendText(text: String, displayId: Int = 0) {
send(SetText(displayId, text))
}
/** /**
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE. * Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager. * Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
*/ */
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
send(SetAudioMode(mode)) send(SetAudioMode(mode))
when (mode) { when (mode) {
pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop() pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop()
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> { pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (currentTransport as? pw.binom.mercury.transport.WifiTransport)?.let { null } val host = (_state.value as? ConnectionState.Connected)?.host
?: (_state.value as? ConnectionState.Connected)?.host if (host != null) {
if (host != null) audioManager.start(host) android.util.Log.i("VmPhoneController", "setAudioMode: ON_PHONE → audioManager.start($host)")
audioManager.start(host)
} else {
// Не подключены — команда уйдёт при следующем подключении не сразу,
// поэтому честно сообщаем в лог: аудио не поднимется.
android.util.Log.w("VmPhoneController", "ON_PHONE, но канал не подключён — аудио не стартует")
} }
} }
} }
android.util.Log.i("VmPhoneController", "setAudioMode done: mode=$mode audioManager=${audioManager}")
}
companion object { companion object {
val Local = staticCompositionLocalOf<VmPhoneController> { val Local = staticCompositionLocalOf<VmPhoneController> {
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -19,23 +20,38 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.mercury.DiscoveredGlasses import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.model.GeneralStatus
import pw.binom.mercury.model.MyWifiInfo
import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryState import pw.binom.rayneovm.phone.mercury.MercuryState
import pw.binom.rayneovm.phone.protocol.ConnectionState 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.phone.bootstrap.ProvisionState
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.shared.AudioMode
import pw.binom.rayneovm.shared.ShizukuStatus
/** /**
* Экран «Очки»: два независимых канала связи + запуск нашего приложения. * Экран «Очки»: два независимых канала связи + запуск нашего приложения.
@@ -53,15 +69,35 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
fun GlassesStatusScreen( fun GlassesStatusScreen(
controller: VmPhoneController, controller: VmPhoneController,
mercuryController: MercuryController, mercuryController: MercuryController,
provisioner: ShizukuProvisioner,
onRequestMercuryPermissions: () -> Unit = {}, onRequestMercuryPermissions: () -> Unit = {},
) { ) {
val connection by controller.state.collectAsState() val connection by controller.state.collectAsState()
val glasses by controller.glassesStatus.collectAsState() val glasses by controller.glassesStatus.collectAsState()
val shizuku by controller.glassesShizuku.collectAsState()
val provisionState by provisioner.state.collectAsState()
val mercury by mercuryController.state.collectAsState() val mercury by mercuryController.state.collectAsState()
val generalStatus by mercuryController.generalStatus.collectAsState()
val wifiNetworks by mercuryController.wifiNetworks.collectAsState()
val wifiScanning by mercuryController.wifiScanning.collectAsState()
val adbAddress by mercuryController.wirelessAdbAddress.collectAsState()
val adbEnabling by mercuryController.wirelessAdbEnabling.collectAsState()
val audioMode by controller.audioMode.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val mercuryConnected = mercury is MercuryState.Connected val mercuryConnected = mercury is MercuryState.Connected
// Открыли вкладку «Очки» при живом BLE — ещё раз спросим статус: нотификации
// от очков приходят только при изменениях, и первый ответ легко теряется.
LaunchedEffect(mercuryConnected) {
if (mercuryConnected) mercuryController.refreshStatus()
}
// При живом WebSocket-канале перезапрашиваем Shizuku-статус очков.
LaunchedEffect(connection) {
if (connection is ConnectionState.Connected) controller.requestShizukuStatus()
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -77,8 +113,28 @@ 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(
shizuku = shizuku,
provision = provisionState,
adbAddress = adbAddress,
adbEnabling = adbEnabling,
canControl = mercuryConnected,
onProvision = { provisioner.force() },
)
HorizontalDivider(color = Color(0xFF2A2A2A)) HorizontalDivider(color = Color(0xFF2A2A2A))
Channel2MercurySection( Channel2MercurySection(
@@ -89,6 +145,39 @@ fun GlassesStatusScreen(
onDisconnect = { mercuryController.disconnect() }, onDisconnect = { mercuryController.disconnect() },
) )
ScreenAndBrightnessSection(
status = generalStatus,
enabled = mercuryConnected,
onBrightness = { mercuryController.setBrightness(it) },
onAutoBrightness = { mercuryController.setAutoBrightness(it) },
)
ScreenPowerSection(
enabled = connection is ConnectionState.Connected,
onSetScreen = { sleep -> controller.setScreen(sleep) },
)
AudioOutputSection(
mode = audioMode,
enabled = connection is ConnectionState.Connected,
onChange = { mode -> controller.setAudioMode(mode) },
)
GlassesWifiSection(
status = generalStatus,
networks = wifiNetworks,
scanning = wifiScanning,
enabled = mercuryConnected,
onToggle = { mercuryController.setWifiEnabled(it) },
onScan = { mercuryController.scanWifi() },
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(
@@ -110,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%" } ?: "—")
@@ -132,6 +221,50 @@ private fun Channel1WebSocketSection(
} }
} }
@Composable
private fun ShizukuSection(
shizuku: ShizukuStatus?,
provision: ProvisionState,
adbAddress: String?,
adbEnabling: Boolean,
canControl: Boolean,
onProvision: () -> Unit,
) {
SectionCard(title = "Shizuku на очках") {
val state = when {
shizuku == null -> "ждём статус…"
shizuku.running && shizuku.granted -> "работает"
shizuku.running -> "работает, нет разрешения"
else -> "не запущен"
}
StatusRow("Состояние", state)
StatusRow("Бутстрап", provision.label)
when {
shizuku?.running == true -> {
// Сервер жив: если прав нет — их выдаёт диалог на очках, ADB не нужен.
if (!shizuku.granted) {
Hint("Разрешение выдаётся диалогом на очках при запуске GlassesApp.")
}
}
else -> {
Hint(
"Если Shizuku не запущен, телефон поднимет его сам: включит ADB over WiFi " +
"на очках, подключится по беспроводному adb и запустит shizuku_server.",
)
if (!canControl) {
Spacer(modifier = Modifier.height(8.dp))
Hint("Для автоподъёма нужен Канал 2 (Mercury BLE).")
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onProvision, enabled = canControl && provision !is ProvisionState.EnablingAdb) {
Text("Поднять Shizuku на очках")
}
adbAddress?.let { StatusRow("ADB-адрес", it) }
}
}
}
}
@Composable @Composable
private fun Channel2MercurySection( private fun Channel2MercurySection(
mercury: MercuryState, mercury: MercuryState,
@@ -153,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)
@@ -171,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))
@@ -185,6 +318,292 @@ private fun Channel2MercurySection(
} }
} }
@Composable
private fun ScreenAndBrightnessSection(
status: GeneralStatus?,
enabled: Boolean,
onBrightness: (Int) -> Unit,
onAutoBrightness: (Boolean) -> Unit,
) {
SectionCard(title = "Яркость") {
if (!enabled) {
Hint("Нужен Канал 2 (Mercury BLE).")
} else if (status == null) {
// Пока статус не пришёл, не показываем нули: очки применяют статус целиком,
// так что «на глазок» выставленная яркость может заодно выключить им WiFi.
Hint("Ждём статус от очков…")
} else {
// Пока палец на слайдере — показываем локальное значение,
// а в очки отправляем один раз, на отпускании (BLE не любит спам).
var drag by remember { mutableStateOf<Float?>(null) }
val brightness = drag ?: status.brightness.toFloat()
StatusRow("Яркость", "${brightness.toInt()}%")
Slider(
value = brightness,
onValueChange = { drag = it },
onValueChangeFinished = {
drag?.let { onBrightness(it.toInt()) }
drag = null
},
valueRange = 0f..100f,
)
ToggleRow(
label = "Авто-яркость",
checked = status.automaticBrightness,
onCheckedChange = onAutoBrightness,
)
}
}
}
@Composable
private fun ScreenPowerSection(
enabled: Boolean,
onSetScreen: suspend (Boolean) -> Boolean,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
fun send(sleep: Boolean, okText: String) {
if (busy || !enabled) return
busy = true
message = null
scope.launch {
try {
val ok = onSetScreen(sleep)
message = if (ok) okText else "Нет Канала 1 — команда не ушла"
} catch (t: Throwable) {
message = "Ошибка: ${t.message}"
} finally {
busy = false
}
}
}
SectionCard(title = "Экран очков") {
Hint("Очки гасят/будят себя сами своим Shizuku-сервисом (Канал 1). Нужен Shizuku на очках.")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
enabled = !busy && enabled,
onClick = { send(true, "Экран погашен") },
) { Text("Погасить") }
OutlinedButton(
enabled = !busy && enabled,
onClick = { send(false, "Экран разбужен") },
) { Text("Разбудить") }
}
if (!enabled) Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
message?.let { Hint(it) }
}
}
@Composable
private fun AudioOutputSection(
mode: AudioMode,
enabled: Boolean,
onChange: suspend (AudioMode) -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val onPhone = mode == AudioMode.ON_PHONE
SectionCard(title = "Звук") {
Hint("Куда выводить звук приложений с виртуального экрана очков.")
ToggleRow(
label = if (onPhone) "Звук на телефоне" else "Звук на очках",
checked = onPhone,
onCheckedChange = { phone ->
if (!busy && enabled) {
busy = true
scope.launch {
try {
onChange(if (phone) AudioMode.ON_PHONE else AudioMode.ON_GLASSES)
} finally {
busy = false
}
}
}
},
)
when {
!enabled -> Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
onPhone -> Hint(
"Динамик очков глушится, звук идёт на телефон. При первом включении " +
"система может на секунду запросить разрешение на захват."
)
}
}
}
/**
* «Убить всё лишнее» на очках: снимает сторонние приложения, RayNeo XR-runtime
* (он держит камеру и греет) и процессы-сироты вроде scrcpy. Наши приложения, Shizuku и
* активный гость на виртуальном дисплее остаются жить.
*/
@Composable
private fun ForceStopSection(
enabled: Boolean,
onForceStop: suspend () -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
SectionCard(title = "Обслуживание") {
Hint(
"Остановит все лишние приложения на очках (включая RayNeo XR-runtime, который " +
"греет) и зеркала экрана. Наше приложение и текущий гость не тронутся.",
)
Button(
enabled = !busy && enabled,
onClick = {
busy = true
message = null
scope.launch {
try {
onForceStop()
message = "Команда отправлена на очки"
} catch (t: Throwable) {
message = "Ошибка: ${t.message}"
} finally {
busy = false
}
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8E2A2A),
disabledContainerColor = Color(0xFF2A2A2A),
),
) {
Text(if (busy) "Отправляем…" else "Убить всё лишнее на очках")
}
if (!enabled) Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
message?.let { Hint(it) }
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,
networks: List<MyWifiInfo>,
scanning: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
onScan: () -> Unit,
onConnect: (String, String?) -> Unit,
) {
var pendingNetwork by remember { mutableStateOf<MyWifiInfo?>(null) }
SectionCard(title = "WiFi очков") {
if (!enabled) {
Hint("Нужен Канал 2 (Mercury BLE).")
} else if (status == null) {
Hint("Ждём статус от очков…")
} else {
ToggleRow(
label = "WiFi включён",
checked = status.wifiEnabled,
onCheckedChange = onToggle,
)
StatusRow("Сеть", status.wifiInfo?.ssid ?: "не подключено")
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(onClick = onScan, enabled = !scanning) {
Text(if (scanning) "Сканируем…" else "Найти сети")
}
if (networks.isNotEmpty()) {
Text("Найденные сети:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
networks.forEach { network ->
NetworkRow(
network = network,
onClick = {
if (network.needsPassword()) {
pendingNetwork = network
} else {
onConnect(network.ssid, null)
}
},
)
}
}
}
}
pendingNetwork?.let { network ->
WifiPasswordDialog(
ssid = network.ssid,
onDismiss = { pendingNetwork = null },
onConfirm = { password ->
onConnect(network.ssid, password)
pendingNetwork = null
},
)
}
}
@Composable
private fun NetworkRow(network: MyWifiInfo, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (network.saved) "${network.ssid} (сохранена)" else network.ssid,
color = Color.White,
fontSize = 14.sp,
)
if (network.needsPassword()) {
Text("нужен пароль", color = Color(0xFFB0B0B0), fontSize = 11.sp)
}
}
Button(onClick = onClick) { Text("Подключить") }
}
}
@Composable
private fun WifiPasswordDialog(ssid: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Пароль WiFi") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(ssid, color = Color(0xFFB0B0B0), fontSize = 13.sp)
OutlinedTextField(
value = password,
onValueChange = { password = it },
singleLine = true,
label = { Text("Пароль") },
visualTransformation = PasswordVisualTransformation(),
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(password) }) { Text("Подключить") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
},
)
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, color = Color(0xFFB0B0B0), fontSize = 14.sp)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable @Composable
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) { private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
Button( Button(
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Mouse
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -21,19 +22,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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.protocol.VmPhoneController import pw.binom.rayneovm.phone.protocol.VmPhoneController
/** /**
* Главный экран приложения: NavigationBar с двумя табами. * Главный экран приложения: NavigationBar с тремя табами.
* *
* - «Очки» — статус связи и состояние GlassesApp * - «Очки» — статус связи и состояние GlassesApp
* - «Приложения» — список приложений для запуска * - «Приложения» — список приложений для запуска
* - «Пульт» — тачпад-мышь и медиа-кнопки для гостя
*/ */
@Composable @Composable
fun HomeScreen( fun HomeScreen(
controller: VmPhoneController, controller: VmPhoneController,
mercuryController: MercuryController, mercuryController: MercuryController,
provisioner: ShizukuProvisioner,
onRequestMercuryPermissions: () -> Unit = {}, onRequestMercuryPermissions: () -> Unit = {},
) { ) {
var selectedTab by remember { mutableStateOf(Tab.GLASSES) } var selectedTab by remember { mutableStateOf(Tab.GLASSES) }
@@ -60,9 +64,11 @@ fun HomeScreen(
Tab.GLASSES -> GlassesStatusScreen( Tab.GLASSES -> GlassesStatusScreen(
controller = controller, controller = controller,
mercuryController = mercuryController, mercuryController = mercuryController,
provisioner = provisioner,
onRequestMercuryPermissions = onRequestMercuryPermissions, onRequestMercuryPermissions = onRequestMercuryPermissions,
) )
Tab.APPS -> AppsListScreen(controller = controller) Tab.APPS -> AppsListScreen(controller = controller)
Tab.REMOTE -> RemoteScreen(controller = controller)
} }
} }
} }
@@ -72,4 +78,5 @@ fun HomeScreen(
private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) { private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
GLASSES("Очки", Icons.Default.Settings), GLASSES("Очки", Icons.Default.Settings),
APPS("Приложения", Icons.Default.Apps), APPS("Приложения", Icons.Default.Apps),
REMOTE("Пульт", Icons.Default.Mouse),
} }
@@ -0,0 +1,264 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.VmDisplay
// android.view.KeyEvent-коды. Здесь обычные числа, чтобы не тянуть android-зависимости в UI-тесты.
private const val KEYCODE_BACK = 4
private const val KEYCODE_DPAD_LEFT = 21
private const val KEYCODE_DPAD_RIGHT = 22
private const val KEYCODE_MEDIA_PLAY = 126
private const val KEYCODE_MEDIA_PAUSE = 127
private const val KEYCODE_DEL = 67
private const val KEYCODE_ENTER = 66
/** Сколько ждать перед кликом-тапом: если за это время коснулись снова — это был скролл. */
private const val CLICK_GRACE_MS = 100L
/**
* Пульт управления гостевым приложением на очках:
* - тачпад-мышь: движение одного пальца → курсор (отыгрывается на очках в обоих глазах),
* тап по тачпаду → клик в текущей позиции курсора;
* - два пальца по тачпаду → скролл (вверх/вниз/вправо/влево) под курсором;
* - кнопки «Перемотка», «Плей», «Вперёд», «Назад», «Пауза»;
* - «Клавиатура» — заглушка (сделаем позже).
*/
@Composable
fun RemoteScreen(
controller: VmPhoneController,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
// Реальное состояние экрана очков (шлют сами очки) — питает кнопку-тумблер ниже.
val screenOn by controller.screenOn.collectAsState()
val connected = controller.state.collectAsState().value is ConnectionState.Connected
// Позиция курсора в координатах VirtualDisplay гостя.
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) }
// Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть»
// до начала скролла. Клик ждёт [CLICK_GRACE_MS]; касание за это время его отменяет.
val pendingClick = remember { mutableStateOf<Job?>(null) }
var showKeyboard by remember { mutableStateOf(false) }
Column(
modifier = modifier.fillMaxSize().background(Color.Black).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF141414))
.pointerInput(Unit) {
// Один палец — курсор; два пальца — скролл (как на тачпаде ноутбука).
awaitEachGesture {
val gainX = VmDisplay.WIDTH / size.width.toFloat()
val gainY = VmDisplay.HEIGHT / size.height.toFloat()
var mode = PadMode.UNDECIDED
var lastCentroid = Offset.Zero
var lastPressedCount = 1
var travel = 0f
awaitFirstDown(requireUnconsumed = false)
// Палец реально коснулся — значит предыдущий тап был началом скролла.
pendingClick.value?.cancel()
pendingClick.value = null
while (true) {
val event = awaitPointerEvent()
val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) break
val centroid = pressed.fold(Offset.Zero) { acc, c -> acc + c.position } /
pressed.size.toFloat()
// Второй палец появился (или жест начался сразу с двух) — скроллим.
if (mode == PadMode.UNDECIDED || (mode == PadMode.CURSOR && pressed.size >= 2)) {
mode = if (pressed.size >= 2) PadMode.SCROLL else PadMode.CURSOR
lastCentroid = centroid
lastPressedCount = pressed.size
continue
}
// Число пальцев изменилось (отпустили один из двух) — центроид скачет,
// этот скачок не скроллим, иначе список прыгает.
if (pressed.size != lastPressedCount) {
lastPressedCount = pressed.size
lastCentroid = centroid
continue
}
val delta = centroid - lastCentroid
lastCentroid = centroid
if (delta.getDistance() <= 0f) continue
travel += delta.getDistance()
when (mode) {
PadMode.CURSOR -> {
// Относительное движение: полный проход по тачпаду = весь экран гостя.
cursor = Offset(
(cursor.x + delta.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
(cursor.y + delta.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
}
PadMode.SCROLL -> {
// Смещение пальцев в пикселях гостя; очки инжектят колесо.
scope.launch {
controller.scrollCursor(delta.x * gainX, delta.y * gainY)
}
}
PadMode.UNDECIDED -> Unit
}
event.changes.forEach { it.consume() }
}
// Отпустили, не сдвинув палец, — это тап (клик по курсору).
if (mode != PadMode.SCROLL && travel < viewConfiguration.touchSlop) {
pendingClick.value = scope.launch {
delay(CLICK_GRACE_MS)
controller.clickCursor()
}
}
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = "Тачпад: один палец — курсор на очках, тап — клик.\nДва пальца — скролл.",
color = Color(0xFF666666),
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
// Мини-индикатор положения курсора (для обратной связи на телефоне).
Canvas(modifier = Modifier.fillMaxSize()) {
val cx = cursor.x / VmDisplay.WIDTH * size.width
val cy = cursor.y / VmDisplay.HEIGHT * size.height
drawCircle(color = Color(0xFFFFEB3B), radius = 8f, center = Offset(cx, cy))
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RemoteButton(Icons.Default.FastRewind, "Перемотка", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_DPAD_LEFT) }
}
RemoteButton(Icons.Default.PlayArrow, "Плей", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_MEDIA_PLAY) }
}
RemoteButton(Icons.Default.FastForward, "Вперёд", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_DPAD_RIGHT) }
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RemoteButton(Icons.AutoMirrored.Filled.ArrowBack, "Назад", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_BACK) }
}
RemoteButton(Icons.Default.Pause, "Пауза", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_MEDIA_PAUSE) }
}
RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f)) {
showKeyboard = true
}
}
// Одна кнопка-тумблер: гасит/будит экран очков. Символ отражает текущее состояние
// (его шлют очки), поэтому нажатие всегда чередует вкл/выкл корректно.
Row(modifier = Modifier.fillMaxWidth()) {
RemoteButton(
icon = Icons.Default.PowerSettingsNew,
label = when (screenOn) {
true -> "Погасить экран очков"
false -> "Разбудить экран очков"
null -> "Экран очков: вкл/выкл"
},
modifier = Modifier.weight(1f),
enabled = connected,
) {
scope.launch { controller.setScreen(sleep = screenOn != false) }
}
}
if (showKeyboard) {
KeyboardDialog(
onDismiss = { showKeyboard = false },
onSend = { text ->
scope.launch { controller.sendText(text) }
showKeyboard = false
},
onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } },
)
}
}
}
@Composable
private fun RemoteButton(
icon: ImageVector,
label: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 12.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = label)
Text(label, fontSize = 11.sp)
}
}
}
/** Режим жеста на тачпаде — определяется по количеству пальцев. */
private enum class PadMode { UNDECIDED, CURSOR, SCROLL }
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -127,30 +128,27 @@ fun ZonaProfile(
KeyboardDialog( KeyboardDialog(
onDismiss = { showKeyboard = false }, onDismiss = { showKeyboard = false },
onSend = { text -> onSend = { text ->
scope.launch { scope.launch { controller.sendText(text) }
text.forEach { ch ->
val keyCode = keyCodeForChar(ch)
if (keyCode != -1) {
controller.send(VmKeyEvent(keyCode = keyCode, action = 1))
}
}
}
showKeyboard = false showKeyboard = false
}, },
onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } },
) )
} }
} }
@Composable @Composable
private fun KeyboardDialog( fun KeyboardDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSend: (String) -> Unit, onSend: (String) -> Unit,
onBackspace: (() -> Unit)? = null,
onEnter: (() -> Unit)? = null,
) { ) {
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Ввод текста") }, title = { Text("Ввод текста") },
text = { text = {
Column {
OutlinedTextField( OutlinedTextField(
value = input, value = input,
onValueChange = { input = it }, onValueChange = { input = it },
@@ -161,6 +159,27 @@ private fun KeyboardDialog(
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
), ),
) )
if (onBackspace != null || onEnter != null) {
androidx.compose.foundation.layout.Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (onBackspace != null) {
androidx.compose.material3.OutlinedButton(
onClick = onBackspace,
modifier = Modifier.weight(1f),
) { Text("⌫ Backspace") }
}
if (onEnter != null) {
androidx.compose.material3.OutlinedButton(
onClick = onEnter,
modifier = Modifier.weight(1f),
) { Text("⏎ Enter") }
}
}
}
}
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { onSend(input) }) { Text("Отправить") } TextButton(onClick = { onSend(input) }) { Text("Отправить") }
@@ -171,25 +190,6 @@ private fun KeyboardDialog(
) )
} }
/**
* ASCII char → Android keyCode. Простая таблица, без юникода и без спец.клавиш.
* Возвращает -1 для символов, которые нельзя передать через KEYCODE.
*/
private fun keyCodeForChar(ch: Char): Int {
return when (ch) {
in 'a'..'z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'a')
in 'A'..'Z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'A')
in '0'..'9' -> android.view.KeyEvent.KEYCODE_0 + (ch - '0')
' ' -> android.view.KeyEvent.KEYCODE_SPACE
'\n' -> android.view.KeyEvent.KEYCODE_ENTER
'.' -> android.view.KeyEvent.KEYCODE_PERIOD
',' -> android.view.KeyEvent.KEYCODE_COMMA
'!' -> android.view.KeyEvent.KEYCODE_1 // shift+1 — отдаём как KEYCODE_1 + shift, но тут упрощённо
'?' -> android.view.KeyEvent.KEYCODE_SLASH
else -> -1
}
}
@Composable @Composable
private fun Spacer(modifier: Modifier) { private fun Spacer(modifier: Modifier) {
androidx.compose.foundation.layout.Spacer(modifier = modifier) androidx.compose.foundation.layout.Spacer(modifier = modifier)
+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.
@@ -0,0 +1,14 @@
package pw.binom.rayneovm.shared
/**
* Параметры виртуального дисплея, на котором запускается гостевое приложение.
*
* Должны совпадать на телефоне и очках: телефон в этих координатах шлёт позицию курсора
* ([CursorMove]) и тач-события ([TouchAction]), очки в этих же координатах создают
* VirtualDisplay и рисуют курсор в обоих глазах.
*/
object VmDisplay {
const val WIDTH = 640
const val HEIGHT = 480
const val DENSITY = 160
}
@@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable
* (настраивается в [VmProtocol]). * (настраивается в [VmProtocol]).
* *
* Потоки: * Потоки:
* - Phone → Glasses: [TouchEvent], [KeyEvent], [LaunchAppCommand], [SetAudioMode], [Shutdown] * - Phone → Glasses: [TouchEvent], [KeyEvent], [CursorMove], [CursorClick], [LaunchAppCommand], [SetAudioMode], [ScreenControl], [Shutdown]
* - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck] * - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck]
* - Двунаправленные: [Ping], [Pong] * - Двунаправленные: [Ping], [Pong]
*/ */
@@ -55,6 +55,44 @@ data class KeyEvent(
} }
} }
/**
* Позиция виртуального курсора мыши в координатах виртуального дисплея гостя
* (0..[VmDisplay.WIDTH]-1, 0..[VmDisplay.HEIGHT]-1).
*
* Очки рисуют курсор в обоих глазах и запоминают позицию как цель для [CursorClick].
*/
@Serializable
@SerialName("cursor_move")
data class CursorMove(
val x: Float,
val y: Float,
) : VmMessage
/**
* Клик виртуальной мыши в текущей позиции курсора ([CursorMove]).
*
* Отдельных DOWN/UP нет намеренно: очки инжектят пару DOWN+UP одной транзакцией
* (см. `ShizukuVdBinder.tap`). Двумя независимыми сообщениями порядок не гарантирован:
* тач-поток у гостя разбалансируется («залипший» палец) и клики перестают работать.
*/
@Serializable
@SerialName("cursor_click")
data object CursorClick : VmMessage
/**
* Скролл в текущей позиции курсора (два пальца по тачпаду).
*
* [dx]/[dy] — смещение пальцев в пикселях координат VirtualDisplay гостя. Очки превращают
* его в колесо мыши (`MotionEvent.ACTION_SCROLL` + `AXIS_HSCROLL`/`AXIS_VSCROLL`) в точке
* [CursorMove], поэтому скроллится то, что под курсором.
*/
@Serializable
@SerialName("cursor_scroll")
data class CursorScroll(
val dx: Float,
val dy: Float,
) : VmMessage
@Serializable @Serializable
@SerialName("launch_app") @SerialName("launch_app")
data class LaunchAppCommand( data class LaunchAppCommand(
@@ -70,14 +108,56 @@ data class SetAudioMode(
@Serializable @Serializable
enum class AudioMode { ON_GLASSES, ON_PHONE } enum class AudioMode { ON_GLASSES, ON_PHONE }
/**
* Передача текста на очки. На очках инжектится в активное поле ввода гостя через
* `ShizukuVdBinder.injectText` (Unicode → InputConnection.commitText), минуя раскладку.
* Используется телефонной клавиатурой; поддерживает русский и английский.
*/
@Serializable
@SerialName("set_text")
data class SetText(
/** DisplayId гостя. 0 = текущий активный. */
val displayId: Int = 0,
val text: String,
) : VmMessage
@Serializable @Serializable
@SerialName("shutdown") @SerialName("shutdown")
data object Shutdown : VmMessage data object Shutdown : VmMessage
/**
* Погасить/разбудить экран очков ([sleep] = true — погасить).
*
* Выполняется на очках их же Shizuku-UserService (инжект `KEYCODE_SLEEP`/`KEYCODE_WAKEUP`
* от shell UID), поэтому не зависит ни от Mercury BLE, ни от wireless-adb.
*/
@Serializable
@SerialName("screen_control")
data class ScreenControl(
val sleep: Boolean,
) : VmMessage
@Serializable @Serializable
@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]). */
@Serializable
@SerialName("request_shizuku_status")
data object RequestShizukuStatus : VmMessage
@Serializable @Serializable
data class AppEntry( data class AppEntry(
val packageName: String, val packageName: String,
@@ -116,6 +196,32 @@ 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 на очках.
*
* [running] — жив ли сервер Shizuku (`Shizuku.pingBinder()`): без него GlassesApp не может
* создать VirtualDisplay, и его надо поднимать через ADB. [granted] — выдано ли нашему
* приложению разрешение (если сервер жив, но прав нет, лечится диалогом на очках, не ADB).
*/
@Serializable
@SerialName("shizuku_status")
data class ShizukuStatus(
val running: Boolean,
val granted: Boolean,
) : VmMessage
// ===================================================================== // =====================================================================
// Bidirectional // Bidirectional
// ===================================================================== // =====================================================================
@@ -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)
}
@@ -46,6 +46,36 @@ class VmProtocolTest {
assertEquals(msg, decoded) assertEquals(msg, decoded)
} }
@Test
fun `CursorMove roundtrip`() {
val msg = CursorMove(x = 123.5f, y = 456.25f)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<CursorMove>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `CursorClick roundtrip`() {
val decoded = VmProtocol.decode<CursorClick>(VmProtocol.encode(CursorClick))
assertEquals(CursorClick, decoded)
}
@Test
fun `CursorScroll roundtrip`() {
val msg = CursorScroll(dx = -3.5f, dy = 12.25f)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<CursorScroll>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `decodeAny for CursorMove uses type discriminator`() {
val msg = VmProtocol.decodeAny("""{"type":"cursor_move","x":10.5,"y":20.25}""")
assertTrue(msg is CursorMove)
assertEquals(10.5f, msg.x)
assertEquals(20.25f, msg.y)
}
@Test @Test
fun `GlassesStatus roundtrip`() { fun `GlassesStatus roundtrip`() {
val msg = GlassesStatus( val msg = GlassesStatus(
@@ -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))
}
}