Compare commits
35 Commits
987f9e5547
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 28d1c02062 | |||
| 65782abfdb | |||
| c02e126bc6 | |||
| 8be9afb592 | |||
| 20d0d05fb4 | |||
| 4743427756 | |||
| de6c648c35 | |||
| d4e1b66924 | |||
| 8cfd9192b3 | |||
| e5d2fa24a1 | |||
| 0f6f07782f | |||
| 3808281d42 | |||
| c485306bc4 | |||
| e3582e34ec | |||
| ad4e458f5c | |||
| 65f3f5d228 | |||
| 3ac76daca1 | |||
| b1efa3d462 | |||
| 2125788e25 | |||
| 9914488258 | |||
| 027e2efb9c | |||
| 8a0b7832c0 | |||
| 59069a741e | |||
| 94f0ed28fc | |||
| 05daf3fc7b | |||
| fe95d249e9 | |||
| c69e8a2e80 | |||
| 3fce3eb3ca | |||
| 03f1d7dcc7 | |||
| 5afdde70e1 | |||
| 42f117796a | |||
| 86bffed6ec | |||
| cc5866b373 | |||
| ca01a7b918 | |||
| e01fafd5a9 |
@@ -0,0 +1,4 @@
|
|||||||
|
build
|
||||||
|
.cortexkit
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
+173
@@ -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».
|
||||||
@@ -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 сам.
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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) на постоянку — можно; при равнозначных развилках
|
||||||
|
выбирать самому и документировать.
|
||||||
@@ -64,6 +64,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
implementation(libs.shizuku.api)
|
implementation(libs.shizuku.api)
|
||||||
|
implementation(libs.shizuku.provider)
|
||||||
implementation(libs.mercury.library)
|
implementation(libs.mercury.library)
|
||||||
implementation(libs.mercury.transport)
|
implementation(libs.mercury.transport)
|
||||||
implementation(libs.mercury.binocular)
|
implementation(libs.mercury.binocular)
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
<!-- Shizuku v12+: BinderSender проверяет requestedPermissions на наличие
|
||||||
|
этого permission (см. moe.shizuku.server.BinderSender.sendBinder).
|
||||||
|
Старое moe.shizuku.permission.SHIZUKU (Shizuku v11) больше не работает. -->
|
||||||
|
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:glEsVersion="0x00020000"
|
android:glEsVersion="0x00020000"
|
||||||
@@ -21,6 +24,24 @@
|
|||||||
android:name="android.hardware.type.embedded"
|
android:name="android.hardware.type.embedded"
|
||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
|
||||||
|
<!-- Android 11+ package visibility:
|
||||||
|
- Shizuku API выполняет context.bindService на сервис внутри пакета
|
||||||
|
moe.shizuku.privileged.api; без явного queries bindService возвращает false
|
||||||
|
и binder к нам никогда не приедет.
|
||||||
|
- queryIntentActivities(MAIN+LAUNCHER) без intent-queries возвращает только
|
||||||
|
системные/RayNeo приложения; для всех сторонних (free.zona и т.п.)
|
||||||
|
нужен явный <intent> с ACTION_MAIN + CATEGORY_LAUNCHER. -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="moe.shizuku.privileged.api" />
|
||||||
|
<intent>
|
||||||
|
<action android:name="rikka.shizuku.intent.action.REQUEST_BINDER" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".GlassesVmApp"
|
android:name=".GlassesVmApp"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -29,29 +50,96 @@
|
|||||||
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
|
||||||
|
через ContentProvider IPC и инициализирует Shizuku-клиент. Без него binder не придёт.
|
||||||
|
permission=INTERACT_ACROSS_USERS_FULL защищает провайдер от обычных приложений. -->
|
||||||
|
<provider
|
||||||
|
android:name="rikka.shizuku.ShizukuProvider"
|
||||||
|
android:authorities="${applicationId}.shizuku"
|
||||||
|
android:multiprocess="false"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
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>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import pw.binom.rayneovm.glasses.protocol.VmGlassesController
|
|||||||
*
|
*
|
||||||
* Запускает [VmService] при создании (foreground-сервис держит WebSocket сервер + mDNS,
|
* Запускает [VmService] при создании (foreground-сервис держит WebSocket сервер + mDNS,
|
||||||
* и продолжает работать после сворачивания Activity).
|
* и продолжает работать после сворачивания Activity).
|
||||||
|
*
|
||||||
|
* Также стартует [ShizukuBootstrap] — он регистрирует binder listener, ждёт/запрашивает
|
||||||
|
* permission и обновляет state. MainActivity потом подпишется на этот state.
|
||||||
*/
|
*/
|
||||||
class GlassesVmApp : Application(), ControllerProvider {
|
class GlassesVmApp : Application(), ControllerProvider {
|
||||||
|
|
||||||
@@ -18,11 +21,15 @@ class GlassesVmApp : Application(), ControllerProvider {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.i(TAG, "GlassesVmApp onCreate, uid=${android.os.Process.myUid()}")
|
Log.i(TAG, "GlassesVmApp onCreate, uid=${android.os.Process.myUid()}")
|
||||||
// Инициализация RayNeo Mercury SDK.
|
|
||||||
com.ffalcon.mercury.android.sdk.MercurySDK.init(this)
|
// 1. RayNeo Mercury SDK init (BLE-канал, если нужно)
|
||||||
Log.i(TAG, "MercurySDK initialized")
|
runCatching { com.ffalcon.mercury.android.sdk.MercurySDK.init(this) }
|
||||||
|
.onSuccess { Log.i(TAG, "MercurySDK initialized") }
|
||||||
|
.onFailure { Log.w(TAG, "MercurySDK init failed: ${it.message}") }
|
||||||
|
|
||||||
controller.setAppContext(this)
|
controller.setAppContext(this)
|
||||||
// Регистрируем debug-receiver для тестирования через adb.
|
|
||||||
|
// 2. Регистрируем debug-receiver для тестирования через adb
|
||||||
val filter = android.content.IntentFilter(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver.ACTION)
|
val filter = android.content.IntentFilter(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver.ACTION)
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter, android.content.Context.RECEIVER_EXPORTED)
|
registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter, android.content.Context.RECEIVER_EXPORTED)
|
||||||
@@ -30,6 +37,11 @@ class GlassesVmApp : Application(), ControllerProvider {
|
|||||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||||
registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter)
|
registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Shizuku bootstrap здесь не зовём — он вызывается из MainActivity.onCreate,
|
||||||
|
// где есть Activity-контекст (для requestPermission flow).
|
||||||
|
|
||||||
|
// 4. Запуск foreground-сервиса (WebSocket + mDNS publisher)
|
||||||
VmService.start(this)
|
VmService.start(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,126 @@
|
|||||||
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
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
/** Последний обработанный tick запроса проекции (см. onCreate). */
|
||||||
|
private var handledTick = 0L
|
||||||
|
|
||||||
|
/** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */
|
||||||
|
private lateinit var controller: VmGlassesController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-Activity приложения: Compose host для GlassesScreen.
|
* Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство
|
||||||
*
|
* `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и
|
||||||
* Никакой навигации — здесь один экран (зеркало гостевого приложения + state overlay).
|
* перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через
|
||||||
|
* [VmGlassesController.sendTempleGesture].
|
||||||
*/
|
*/
|
||||||
class MainActivity : ComponentActivity() {
|
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()
|
||||||
|
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) {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
@@ -34,10 +129,101 @@ class MainActivity : ComponentActivity() {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
) {
|
) {
|
||||||
GlassesScreen(controller = controller)
|
val shizukuState by ShizukuBootstrap.state.collectAsState()
|
||||||
|
DriveShizukuState(controller, shizukuState)
|
||||||
|
GlassesScreen(
|
||||||
|
controller = controller,
|
||||||
|
onRequestShizukuPermission = { ShizukuBootstrap.refresh() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
AudioBridge.activityResumed = true
|
||||||
|
ShizukuBootstrap.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки
|
||||||
|
* (`cyttsp6_mt`) не ассоциированы с дисплеем и иначе теряются: `onTouchEvent` Activity
|
||||||
|
* при этом может не вызываться вовсе. Всё, что пришло с дужки, отдаём распознавателю и
|
||||||
|
* потребляем, чтобы не утекло в UI.
|
||||||
|
*/
|
||||||
|
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
// Диагностика: на ACTION_DOWN печатаем устройство — видно, доходят ли касания дужки
|
||||||
|
// (deviceId тача дужки = 4, `cyttsp5_mt`) до окна Activity вообще.
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"touch down: deviceId=${event.deviceId} source=0x${event.source.toString(16)} " +
|
||||||
|
"x=${event.x} y=${event.y}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val isTemple = event.deviceId == templeDeviceId && templeDeviceId != -1
|
||||||
|
if (!isTemple) return super.dispatchTouchEvent(event)
|
||||||
|
Log.d(TAG, "temple: action=${event.actionMasked} x=${event.x} y=${event.y}")
|
||||||
|
templeGestureDetector.onTouchEvent(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
AudioBridge.activityResumed = false
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<String>,
|
||||||
|
grantResults: IntArray,
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == ShizukuBootstrap.REQUEST_CODE_PERMISSION) {
|
||||||
|
val grant = grantResults.firstOrNull() ?: -1
|
||||||
|
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
|
||||||
|
private fun DriveShizukuState(controller: VmGlassesController, shizuku: ShizukuBootstrap.AllowState) {
|
||||||
|
LaunchedEffect(shizuku) {
|
||||||
|
when (shizuku) {
|
||||||
|
ShizukuBootstrap.AllowState.NotRunning,
|
||||||
|
ShizukuBootstrap.AllowState.Waiting,
|
||||||
|
ShizukuBootstrap.AllowState.NeedsPermission -> {
|
||||||
|
if (controller.state.value !is GlassesScreenState.Running) {
|
||||||
|
controller.setState(GlassesScreenState.ShizukuMissing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShizukuBootstrap.AllowState.Granted -> {
|
||||||
|
// Если мы сейчас в ShizukuMissing — переходим в NoAppSelected.
|
||||||
|
// Running не трогаем: приложение уже запущено, Shizuku-статус не должен его ронять.
|
||||||
|
if (controller.state.value is GlassesScreenState.ShizukuMissing) {
|
||||||
|
controller.setState(GlassesScreenState.NoAppSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package pw.binom.rayneovm.glasses
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import rikka.shizuku.Shizuku
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Управляет жизненным циклом Shizuku для GlassesApp.
|
||||||
|
*
|
||||||
|
* Состояния:
|
||||||
|
* - Waiting — Shizuku ещё не ответил (binder listener ждёт)
|
||||||
|
* - NotRunning — Shizuku не запущен (нет сервера)
|
||||||
|
* - NeedsPermission — Shizuku запущен, но прав ещё нет
|
||||||
|
* - Granted — права есть, можно работать
|
||||||
|
*
|
||||||
|
* Использование (см. https://github.com/RikkaApps/Shizuku-API):
|
||||||
|
* 1. В AndroidManifest.xml добавить `<provider rikka.shizuku.ShizukuProvider .../>` (уже есть).
|
||||||
|
* 2. В Application.onCreate / Activity.onCreate один раз:
|
||||||
|
* Shizuku.addBinderReceivedListenerSticky(...)
|
||||||
|
* Shizuku.addRequestPermissionResultListener(...)
|
||||||
|
* 3. Проверять ShizukuBootstrap.state.
|
||||||
|
*
|
||||||
|
* Зависимость `dev.rikka.shizuku:provider` нужна (в ней ShizukuProvider).
|
||||||
|
*/
|
||||||
|
object ShizukuBootstrap {
|
||||||
|
|
||||||
|
enum class AllowState {
|
||||||
|
Waiting,
|
||||||
|
NotRunning,
|
||||||
|
NeedsPermission,
|
||||||
|
Granted,
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "ShizukuBootstrap"
|
||||||
|
const val REQUEST_CODE_PERMISSION = 7801
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AllowState.Waiting)
|
||||||
|
val state: StateFlow<AllowState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile private var binderReceivedCalled = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Привязывает listeners к Shizuku (по доке). Вызывать из Activity.onCreate().
|
||||||
|
*
|
||||||
|
* - addBinderReceivedListenerSticky — сразу же выстреливает, если binder уже получен
|
||||||
|
* (без "sticky" мы бы пропустили).
|
||||||
|
* - addRequestPermissionResultListener — вызовется после диалога.
|
||||||
|
*
|
||||||
|
* ShizukuProvider из `dev.rikka.shizuku:provider` инициализирует binder до того,
|
||||||
|
* как наш процесс получит управление в onCreate (через ContentProvider.onCreate),
|
||||||
|
* поэтому pingBinder() сразу вернёт true — если Shizuku-сервер живой и v12+.
|
||||||
|
*/
|
||||||
|
fun attach(activity: Activity) {
|
||||||
|
// 1. Подписка на приход binder-а (sticky ⇒ сразу же вызовется, если уже есть).
|
||||||
|
Shizuku.addBinderReceivedListenerSticky(object : Shizuku.OnBinderReceivedListener {
|
||||||
|
override fun onBinderReceived() {
|
||||||
|
if (binderReceivedCalled) return
|
||||||
|
binderReceivedCalled = true
|
||||||
|
Log.i(TAG, "onBinderReceived")
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Подписка на результат диалога разрешения.
|
||||||
|
Shizuku.addRequestPermissionResultListener { _, grantResult ->
|
||||||
|
Log.i(TAG, "onRequestPermissionResult: grant=$grantResult")
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Сразу пробуем — может, binder уже есть.
|
||||||
|
if (Shizuku.pingBinder()) {
|
||||||
|
Log.i(TAG, "attach: pingBinder сразу ok")
|
||||||
|
refresh()
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "attach: pingBinder=false, ждём binder от ShizukuProvider")
|
||||||
|
_state.value = AllowState.Waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перечитывает состояние и (если нужно) запрашивает permission. */
|
||||||
|
fun refresh() {
|
||||||
|
val running = try { Shizuku.pingBinder() } catch (t: Throwable) { false }
|
||||||
|
if (!running) {
|
||||||
|
Log.w(TAG, "refresh: Shizuku не запущен")
|
||||||
|
_state.value = AllowState.NotRunning
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val perm = try {
|
||||||
|
Shizuku.checkSelfPermission()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "checkSelfPermission failed", t)
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
if (perm == 0 /* PERMISSION_GRANTED */) {
|
||||||
|
Log.i(TAG, "refresh: granted")
|
||||||
|
_state.value = AllowState.Granted
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "refresh: needs permission (perm=$perm)")
|
||||||
|
if (_state.value != AllowState.NeedsPermission) {
|
||||||
|
_state.value = AllowState.NeedsPermission
|
||||||
|
try {
|
||||||
|
Shizuku.requestPermission(REQUEST_CODE_PERMISSION)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "requestPermission failed", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* override fun onRequestPermissionsResult(rc: Int, p: Array<String>, g: IntArray) {
|
||||||
|
* ...
|
||||||
|
* ShizukuBootstrap.onRequestPermissionResult(rc, g[0])
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
|
||||||
|
// Shizuku.addRequestPermissionResultListener вызывается автоматически внутри Shizuku,
|
||||||
|
// здесь делать ничего не надо. Метод оставлен как no-op для удобства и обратной совместимости.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,31 +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.ForceStopAll
|
||||||
|
import pw.binom.rayneovm.shared.AppEntry
|
||||||
|
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 на стороне очков.
|
||||||
@@ -49,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 {
|
||||||
@@ -73,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) {
|
||||||
@@ -88,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) {
|
||||||
@@ -111,22 +235,331 @@ class VmService : LifecycleService() {
|
|||||||
when (msg) {
|
when (msg) {
|
||||||
is LaunchAppCommand -> {
|
is LaunchAppCommand -> {
|
||||||
Log.i(TAG, "LaunchAppCommand: ${msg.packageName}")
|
Log.i(TAG, "LaunchAppCommand: ${msg.packageName}")
|
||||||
// Реальный запуск — через Shizuku service (см. VmShizukuService).
|
// Реальный запуск: VirtualDisplay + launchActivity через Shizuku.
|
||||||
// Пока — переключаем состояние для UI.
|
// acquireFrameSource сам освобождает предыдущий VirtualDisplay, если был.
|
||||||
controller.setState(GlassesScreenState.Running(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.setState(GlassesScreenState.NoAppSelected)
|
controller.stopGuest()
|
||||||
}
|
}
|
||||||
|
is ForceStopAll -> {
|
||||||
|
Log.i(TAG, "ForceStopAll")
|
||||||
|
controller.forceStopAll()
|
||||||
|
}
|
||||||
|
is GetAppList -> {
|
||||||
|
Log.i(TAG, "GetAppList")
|
||||||
|
val apps = listInstalledLaunchableApps()
|
||||||
|
val response = AppListResponse(apps = apps)
|
||||||
|
val json = VmProtocol.encode(response)
|
||||||
|
try {
|
||||||
|
server!!.send(connId, json)
|
||||||
|
Log.i(TAG, "Sent app list: ${apps.size} apps")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
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) {
|
||||||
@@ -153,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
|
||||||
@@ -218,9 +663,33 @@ class VmService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun listInstalledLaunchableApps(): List<AppEntry> {
|
||||||
|
val pm = packageManager
|
||||||
|
val mainIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
|
||||||
|
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
|
||||||
|
// MATCH_ALL: на Android 11+ без `<queries>` для конкретного пакета обычные флаги
|
||||||
|
// могут не возвращать часть приложений. free.zona — пример.
|
||||||
|
val flags = android.content.pm.PackageManager.MATCH_ALL or
|
||||||
|
android.content.pm.PackageManager.GET_RESOLVED_FILTER
|
||||||
|
val resolved = pm.queryIntentActivities(mainIntent, flags)
|
||||||
|
return resolved.map { ri ->
|
||||||
|
AppEntry(
|
||||||
|
packageName = ri.activityInfo.packageName,
|
||||||
|
displayName = ri.loadLabel(pm).toString(),
|
||||||
|
)
|
||||||
|
}.sortedBy { it.displayName.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
@@ -241,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, "проекция сохранена (живёт до конца процесса)")
|
||||||
|
}
|
||||||
|
}
|
||||||
-159
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+152
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+692
-24
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контроллер состояния экрана очков и кадров.
|
* Контроллер состояния экрана очков и кадров.
|
||||||
@@ -26,31 +42,74 @@ class VmGlassesController {
|
|||||||
|
|
||||||
private val testScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val testScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
private val _state = MutableStateFlow<GlassesScreenState>(GlassesScreenState.Starting)
|
private val _state = MutableStateFlow<GlassesScreenState>(GlassesScreenState.NoAppSelected)
|
||||||
val state: StateFlow<GlassesScreenState> = _state.asStateFlow()
|
val state: StateFlow<GlassesScreenState> = _state.asStateFlow()
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
activeSource?.release()
|
// Смена приложения: прошлого гостя сначала УБИВАЕМ, и только потом рвём его дисплей —
|
||||||
activeSource = null
|
// иначе задача переезжает на display 0 и вылезает во весь экран (см. [stopGuest]).
|
||||||
|
killActiveGuest()
|
||||||
|
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 = VirtualDisplayFrameSource.acquire(
|
val source = try {
|
||||||
|
VirtualDisplayFrameSource.acquire(
|
||||||
context = ctx,
|
context = ctx,
|
||||||
surfaceTexture = surfaceTexture,
|
surfaceTexture = surfaceTexture,
|
||||||
serviceClass = VmShizukuService::class.java,
|
serviceClass = VmShizukuService::class.java,
|
||||||
@@ -58,30 +117,562 @@ class VmGlassesController {
|
|||||||
height = VIRTUAL_HEIGHT,
|
height = VIRTUAL_HEIGHT,
|
||||||
density = VIRTUAL_DENSITY,
|
density = VIRTUAL_DENSITY,
|
||||||
)
|
)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "VirtualDisplayFrameSource.acquire failed for $packageName", t)
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
activeSource = source
|
activeSource = source
|
||||||
return source
|
activeGuestPackage = packageName
|
||||||
|
_frameSource.value = source
|
||||||
|
source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Освобождает активный VirtualDisplay и очищает FrameSource. */
|
||||||
|
fun releaseFrameSource() {
|
||||||
|
val src = activeSource ?: run {
|
||||||
|
_frameSource.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
src.release()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "releaseFrameSource failed", t)
|
||||||
|
}
|
||||||
|
activeSource = 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). */
|
||||||
|
fun launchGuest(packageName: String) {
|
||||||
|
Log.i(TAG, "launchGuest: $packageName")
|
||||||
|
setState(GlassesScreenState.Running(packageName))
|
||||||
|
// Курсор появляется по центру экрана — иначе первый клик уйдёт «в никуда».
|
||||||
|
_cursor.value = CursorPosition(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)
|
||||||
|
|
||||||
|
// Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
|
||||||
|
// Без Shizuku acquireFrameSource вернёт null → гость не запустится.
|
||||||
|
testScope.launch {
|
||||||
|
val src = acquireFrameSource(packageName) as? VirtualDisplayFrameSource
|
||||||
|
if (src == null) {
|
||||||
|
Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku не активен?)")
|
||||||
|
setState(GlassesScreenState.Error("Не удалось создать виртуальный экран (Shizuku?)"))
|
||||||
|
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 или смене приложения).
|
||||||
|
*
|
||||||
|
* Порядок критичен: СНАЧАЛА force-stop гостя, потом release дисплея. Если освободить
|
||||||
|
* дисплей первым, система не убивает процесс, а переносит задачу на основной дисплей —
|
||||||
|
* приложение вылезает на очках во весь экран (так было с `free.zona`).
|
||||||
|
*
|
||||||
|
* Всё под [acquireMutex]: параллельный запуск нового гостя не должен поднять дисплей,
|
||||||
|
* пока мы добиваем старого.
|
||||||
|
*/
|
||||||
|
fun stopGuest() {
|
||||||
|
Log.i(TAG, "stopGuest")
|
||||||
|
if (activeSource == null && activeGuestPackage == null) {
|
||||||
|
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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,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")
|
||||||
@@ -109,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)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-5
@@ -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
|
||||||
@@ -39,50 +41,97 @@ import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
|||||||
fun GlassesScreen(
|
fun GlassesScreen(
|
||||||
controller: VmGlassesController,
|
controller: VmGlassesController,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
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" />
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-20
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-7
@@ -15,10 +15,23 @@ import pw.binom.mercury.transport.StringTransport
|
|||||||
import pw.binom.mercury.transport.Transports
|
import pw.binom.mercury.transport.Transports
|
||||||
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
|
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
|
||||||
import pw.binom.rayneovm.shared.AppCrashedEvent
|
import pw.binom.rayneovm.shared.AppCrashedEvent
|
||||||
|
import pw.binom.rayneovm.shared.AppEntry
|
||||||
|
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.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
|
||||||
|
|
||||||
@@ -40,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() }
|
||||||
@@ -69,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 ->
|
||||||
@@ -84,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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -116,6 +159,15 @@ class VmPhoneController(
|
|||||||
is AppLaunchedAck -> {
|
is AppLaunchedAck -> {
|
||||||
_appLaunched.value = msg
|
_appLaunched.value = msg
|
||||||
}
|
}
|
||||||
|
is AppListResponse -> {
|
||||||
|
_appsOnGlasses.value = msg.apps
|
||||||
|
}
|
||||||
|
is ShizukuStatus -> {
|
||||||
|
_glassesShizuku.value = msg
|
||||||
|
}
|
||||||
|
is ScreenState -> {
|
||||||
|
_screenOn.value = msg.on
|
||||||
|
}
|
||||||
is SetAudioMode -> {
|
is SetAudioMode -> {
|
||||||
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
|
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
|
||||||
// AudioCaptureService на очках стартует/останавливается.
|
// AudioCaptureService на очках стартует/останавливается.
|
||||||
@@ -132,9 +184,30 @@ class VmPhoneController(
|
|||||||
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
|
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
|
||||||
val appLaunched: StateFlow<AppLaunchedAck?> = _appLaunched.asStateFlow()
|
val appLaunched: StateFlow<AppLaunchedAck?> = _appLaunched.asStateFlow()
|
||||||
|
|
||||||
|
private val _appsOnGlasses = MutableStateFlow<List<AppEntry>>(emptyList())
|
||||||
|
val appsOnGlasses: StateFlow<List<AppEntry>> = _appsOnGlasses.asStateFlow()
|
||||||
|
|
||||||
fun clearAppCrashed() { _appCrashed.value = null }
|
fun clearAppCrashed() { _appCrashed.value = null }
|
||||||
fun clearAppLaunched() { _appLaunched.value = null }
|
fun clearAppLaunched() { _appLaunched.value = null }
|
||||||
|
|
||||||
|
/** Просит очки прислать список установленных приложений. Игнорирует, если не подключены. */
|
||||||
|
suspend fun requestAppList() {
|
||||||
|
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
|
||||||
@@ -145,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> {
|
||||||
|
|||||||
@@ -7,25 +7,31 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
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.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
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.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.rayneovm.phone.protocol.VmPhoneController
|
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||||
|
import pw.binom.rayneovm.shared.AppEntry
|
||||||
import pw.binom.rayneovm.shared.LaunchAppCommand
|
import pw.binom.rayneovm.shared.LaunchAppCommand
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список приложений, которые можно запустить на очках через VirtualDisplay.
|
* Список приложений, установленных на ОЧКАХ. Тап → запуск на очках.
|
||||||
*
|
* Список приходит динамически по WebSocket (VmMessage.GetAppList → AppListResponse).
|
||||||
* Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AppsListScreen(
|
fun AppsListScreen(
|
||||||
@@ -33,59 +39,77 @@ fun AppsListScreen(
|
|||||||
onAppClick: (String) -> Unit = {},
|
onAppClick: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val glasses by controller.glassesStatus.collectAsState()
|
val glasses by controller.glassesStatus.collectAsState()
|
||||||
|
val apps by controller.appsOnGlasses.collectAsState()
|
||||||
val isRunning = glasses?.running == true
|
val isRunning = glasses?.running == true
|
||||||
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
|
|
||||||
val apps = listOf(
|
LaunchedEffect(Unit) {
|
||||||
AppEntry("free.zona", "Zona", "Кинотеатр Zona"),
|
controller.requestAppList()
|
||||||
)
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Приложения",
|
text = "Приложения на очках",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Тапните, чтобы запустить на очках",
|
text = "Тап — запуск на очках. На телефоне сами приложения не запускаются.",
|
||||||
color = Color(0xFFB0B0B0),
|
color = Color(0xFFB0B0B0),
|
||||||
fontSize = 14.sp,
|
fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (apps.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Нет связи с очками или список пуст.",
|
||||||
|
color = Color(0xFF888888),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
apps.forEach { app ->
|
apps.forEach { app ->
|
||||||
Card(
|
AppCard(app, isRunning && glasses?.currentApp == app.packageName) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
controller.send(LaunchAppCommand(packageName = app.packageName))
|
controller.send(LaunchAppCommand(packageName = app.packageName))
|
||||||
}
|
}
|
||||||
onAppClick(app.packageName)
|
onAppClick(app.packageName)
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppCard(app: AppEntry, running: Boolean, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
containerColor = Color(0xFF1A1A1A),
|
containerColor = Color(0xFF1A1A1A),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = app.name,
|
text = app.displayName,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = app.description,
|
text = app.packageName,
|
||||||
color = Color(0xFFB0B0B0),
|
color = Color(0xFFB0B0B0),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
if (isRunning && glasses?.currentApp == app.packageName) {
|
if (running) {
|
||||||
Text(
|
Text(
|
||||||
text = "● запущено",
|
text = "● запущено",
|
||||||
color = Color(0xFF4CAF50),
|
color = Color(0xFF4CAF50),
|
||||||
@@ -95,11 +119,3 @@ fun AppsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class AppEntry(
|
|
||||||
val packageName: String,
|
|
||||||
val name: String,
|
|
||||||
val description: String,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+64
-3
@@ -915,8 +915,21 @@ class AudioPlaybackService : Service() {
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <!-- AudioPlaybackService -->
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <!-- AudioPlaybackService -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Android 13+ для foreground notification -->
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Android 13+ для foreground notification -->
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- PARTIAL_WAKE_LOCK для AudioPlaybackService -->
|
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- PARTIAL_WAKE_LOCK для AudioPlaybackService -->
|
||||||
<!-- Shizuku permission для VM-сервиса -->
|
<!-- Shizuku permission v12+ (BinderSender проверяет requestedPermissions на наличие
|
||||||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
этого permission; старое moe.shizuku.permission.SHIZUKU v11 не работает). -->
|
||||||
|
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shizuku Provider (v12+ обязательно, см. ниже §5.3.1):**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<provider
|
||||||
|
android:name="rikka.shizuku.ShizukuProvider"
|
||||||
|
android:authorities="${applicationId}.shizuku"
|
||||||
|
android:multiprocess="false"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||||
```
|
```
|
||||||
|
|
||||||
**PhoneApp (`app-phone-vm/AndroidManifest.xml`):**
|
**PhoneApp (`app-phone-vm/AndroidManifest.xml`):**
|
||||||
@@ -934,9 +947,57 @@ class AudioPlaybackService : Service() {
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- для BLE scan на Android 6-11 -->
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- для BLE scan на Android 6-11 -->
|
||||||
<!-- Shizuku permission (необязательно, у нас только Mercury-канал) -->
|
<!-- Shizuku permission (необязательно, у нас только Mercury-канал) -->
|
||||||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5.3.1. Интеграция с Shizuku
|
||||||
|
|
||||||
|
**Цель.** GlassesApp использует Shizuku (см. память #4779) как обёртку над `IDisplayManager.createVirtualDisplay` без флага `OWN_CONTENT_ONLY` — единственный способ создать VirtualDisplay, на который можно стартовать чужие Activity (`ActivityOptions.setLaunchDisplayId`).
|
||||||
|
|
||||||
|
**Зависимости (`gradle/libs.versions.toml`):**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
shizukuApi = "13.1.5"
|
||||||
|
shizukuProvider = "13.1.5"
|
||||||
|
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizukuApi" }
|
||||||
|
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizukuProvider" }
|
||||||
|
```
|
||||||
|
|
||||||
|
В `app-glasses-vm/build.gradle.kts` — `implementation(libs.shizuku.api)` + `implementation(libs.shizuku.provider)`.
|
||||||
|
|
||||||
|
**Манифест — обязательная структура (см. https://github.com/RikkaApps/Shizuku-API):**
|
||||||
|
|
||||||
|
1. `<uses-permission android:name="moe.shizuku.manager.permission.API_V23"/>` (v12+). Старое `moe.shizuku.permission.SHIZUKU` (v11) НЕ работает — BinderSender на стороне сервера проверяет, что в `requestedPermissions` есть именно `API_V23`.
|
||||||
|
2. `<provider android:name="rikka.shizuku.ShizukuProvider" android:authorities="${applicationId}.shizuku" android:exported="true" android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"/>` — этот ContentProvider из либы `rikka.shizuku:provider` через свой `onCreate` сам вытаскивает binder у Shizuku-сервера через ContentProvider IPC. **Без него binder к нам никогда не придёт.** Кастомные ContentProvider'ы (например, наш `ShizukuInitProvider`) этого **не заменяют** — у `rikka.shizuku.ShizukuProvider` специальные системные AIDL-хуки.
|
||||||
|
3. `<queries><package android:name="moe.shizuku.privileged.api"/></queries>` — для Android 11+ package visibility (`bindService` к сервису внутри пакета Shizuku-менеджера иначе возвращает false).
|
||||||
|
|
||||||
|
**Activity-side (MainActivity.onCreate, строго после `super.onCreate`):**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
Shizuku.addBinderReceivedListenerSticky(object : Shizuku.OnBinderReceivedListener {
|
||||||
|
override fun onBinderReceived() { refresh() }
|
||||||
|
})
|
||||||
|
Shizuku.addRequestPermissionResultListener { _, grant -> refresh() }
|
||||||
|
if (Shizuku.pingBinder()) refresh() else state.value = AllowState.Waiting
|
||||||
|
// где refresh():
|
||||||
|
// running = Shizuku.pingBinder(); perm = Shizuku.checkSelfPermission()
|
||||||
|
// если perm == GRANTED -> Granted, иначе NeedsPermission + requestPermission(code)
|
||||||
|
```
|
||||||
|
|
||||||
|
И override `onRequestPermissionsResult(...)` в Activity (нужен для прокидывания результата диалога).
|
||||||
|
|
||||||
|
**Запрос разрешения и диалог.** Когда пользователь первый раз запускает GlassesApp:
|
||||||
|
1. `Shizuku.checkSelfPermission()` возвращает `PERMISSION_DENIED`.
|
||||||
|
2. `Shizuku.requestPermission(code)` → Shizuku-сервер стартует `RequestPermissionActivity` от `moe.shizuku.privileged.api`.
|
||||||
|
3. Activity показывает диалог «Allow rayneo-vm to use Shizuku?». После Allow — `onRequestPermissionResult` выстреливает, `refresh()` ставит state=Granted.
|
||||||
|
|
||||||
|
**Race condition на RayNeo X3 Pro (см. память #4778).** Системный лаунчер `com.ffalconxr.mercury.launcher` (PID 24482, UID 1000) **иногда** принудительно убивает (`forceStopPackage`) только что запущенную `RequestPermissionActivity` в течение ~14мс. То есть диалог либо показывается, либо нет — гонка. Workaround: при `state==NeedsPermission` после рестарта активности пользователь тапнет ещё раз. В коде: в `refresh()` если `requestPermission()` бросил — повторить на следующем `onResume()`.
|
||||||
|
|
||||||
|
**Что НЕ работает:**
|
||||||
|
- Кастомный ContentProvider вместо `rikka.shizuku.ShizukuProvider` — binder не придёт.
|
||||||
|
- `addBinderReceivedListener` без `Sticky` — пропустим событие, если binder пришёл до того, как мы зарегистрировались.
|
||||||
|
- `requestPermission()` без Activity-контекста — сервер стартует Activity, но если она умерла до того, как пользователь нажал Allow, результат не вернётся.
|
||||||
|
|
||||||
### 5.4. Дефолты (фиксировано для ночной сессии)
|
### 5.4. Дефолты (фиксировано для ночной сессии)
|
||||||
|
|
||||||
**Версии — latest stable на сентябрь 2026 (по правилу «брать последнее по возможности»):**
|
**Версии — latest stable на сентябрь 2026 (по правилу «брать последнее по возможности»):**
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ lifecycle = "2.11.0"
|
|||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
composeBom = "2026.09.00"
|
composeBom = "2026.09.00"
|
||||||
shizukuApi = "13.1.5"
|
shizukuApi = "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
|
||||||
@@ -45,6 +46,7 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.15.0" }
|
|||||||
|
|
||||||
# Shizuku
|
# Shizuku
|
||||||
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizukuApi" }
|
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizukuApi" }
|
||||||
|
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizukuProvider" }
|
||||||
|
|
||||||
# rayneo-mercury (библиотеки)
|
# rayneo-mercury (библиотеки)
|
||||||
mercury-library = { module = "pw.binom.mercury:rayneo-mercury", version.ref = "mercury" }
|
mercury-library = { module = "pw.binom.mercury:rayneo-mercury", version.ref = "mercury" }
|
||||||
|
|||||||
@@ -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,10 +108,68 @@ 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
|
||||||
|
@SerialName("get_app_list")
|
||||||
|
data object GetAppList : VmMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
|
||||||
|
* (держит залипшую камеру и греет) + добить процессы-сироты вроде scrcpy.
|
||||||
|
*
|
||||||
|
* Выполняется на очках их Shizuku UserService (shell UID). Защищены от убийства: сам
|
||||||
|
* GlassesApp, Shizuku, все системные/привилегированные пакеты и текущий гость на
|
||||||
|
* VirtualDisplay.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("force_stop_all")
|
||||||
|
data object ForceStopAll : VmMessage
|
||||||
|
|
||||||
|
/** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("request_shizuku_status")
|
||||||
|
data object RequestShizukuStatus : VmMessage
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppEntry(
|
||||||
|
val packageName: String,
|
||||||
|
val displayName: String,
|
||||||
|
) : VmMessage
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("app_list")
|
||||||
|
data class AppListResponse(
|
||||||
|
val apps: List<AppEntry>,
|
||||||
|
) : VmMessage
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Glasses → Phone
|
// Glasses → Phone
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -100,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user