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