1370 lines
94 KiB
Markdown
1370 lines
94 KiB
Markdown
# ТЗ: rayneo-vm — запуск произвольного приложения на очках RayNeo X3 Pro
|
||
|
||
> **Статус документа:** черновик (v0.6). Будет уточняться по ходу разработки.
|
||
|
||
---
|
||
|
||
## 1. Цель
|
||
|
||
Сделать систему из **двух подмодулей** + **общей либы**, которая позволяет запускать произвольное Android-приложение (например, онлайн-кинотеатр **Zona**) на умных очках **RayNeo X3 Pro** и управлять им с телефона.
|
||
|
||
### 1.1. Подмодули
|
||
|
||
| Подмодуль | Где работает | Что делает |
|
||
|---|---|---|
|
||
| **`app-glasses-vm`** (GlassesApp) | на очках RayNeo X3 Pro | Создаёт `VirtualDisplay` 640×480, запускает на нём «гостевое» приложение, захватывает кадры и рисует их в обе половины физического дисплея очков (1280×480). Принимает команды управления от телефона. |
|
||
| **`app-phone-vm`** (PhoneApp) | на обычном Android-телефоне | UI с тачпадом и кнопкой клавиатуры. Передаёт события тачпада и клавиатуры на очки. |
|
||
|
||
### 1.2. Общая либа `rayneo-mercury`
|
||
|
||
Переиспользуемые компоненты живут как **новые модули** внутри существующей либы `/home/subochev/common-projects/WORK/rayneo-mercury`:
|
||
|
||
| Модуль | Что внутри |
|
||
|---|---|
|
||
| `:transport` | Канал связи телефон ↔ очки: WebSocket по WiFi, Bluetooth fallback, mDNS-дискавери, общий протокол сообщений. Переносим из view-mate/lib-core (адаптируем namespace и зависимости). |
|
||
| `:binocular-renderer` | Обобщённый бинокулярный EGL-рендерер. Принимает «источник кадров» (интерфейс) и рисует его в обе половины экрана очков. Источник может быть видеоплеер (view-mate) или `SurfaceTexture` от VirtualDisplay (rayneo-vm). |
|
||
|
||
view-mate ничего не правим — другой агент мигрирует его на новые модули позже.
|
||
|
||
---
|
||
|
||
## 2. Термины
|
||
|
||
| Термин | Значение |
|
||
|---|---|
|
||
| **Glasses** | RayNeo X3 Pro (модель ARGF20, Android 12, API 32). |
|
||
| **Phone** | Обычный Android-телефон (компаньон). |
|
||
| **GlassesApp** | Подмодуль `app-glasses-vm` — приложение, работающее на очках. |
|
||
| **PhoneApp** | Подмодуль `app-phone-vm` — приложение, работающее на телефоне. |
|
||
| **VM-сервис** | Shizuku UserService внутри GlassesApp — в нём делаем всю работу с системными API (создание VirtualDisplay, запуск Activity). |
|
||
| **Виртуальный экран (VirtualDisplay)** | Программно созданный Android-дисплей 640×480 landscape, на котором работает «гостевое» приложение. |
|
||
| **Гостевое приложение** | `free.zona` — онлайн-кинотеатр, который мы запускаем на VirtualDisplay. |
|
||
| **Бинокулярный вывод** | Отображение одной и той же картинки дважды: левая половина → левый глаз, правая половина → правый глаз. |
|
||
| **Канал связи** | WebSocket-соединение между GlassesApp и PhoneApp (WiFi) с Bluetooth-фоллбеком. |
|
||
| **binocular-renderer** | Общий модуль в rayneo-mercury, умеет рисовать произвольный источник кадров в обе половины экрана очков. |
|
||
|
||
---
|
||
|
||
## 3. Архитектура
|
||
|
||
```
|
||
Телефон (PhoneApp) Очки (GlassesApp)
|
||
┌─────────────────────┐ ┌──────────────────────────┐
|
||
│ │ WebSocket (WiFi) │ MainActivity │
|
||
│ Тачпад (тапы/ │◄──────────────────────►│ (binocular-renderer) │
|
||
│ свайпы/жесты) │ BT (fallback) │ │
|
||
│ │ │ ┌────────────────────┐ │
|
||
│ Кнопка «клавиатура»│ │ │ Shizuku UserService│ │
|
||
│ → системная │ │ │ │ │
|
||
│ клавиатура │ │ │ • IDisplayManager │ │
|
||
│ │ │ │ .createVirtual │ │
|
||
│ Статус-индикатор: │ │ │ Display(...) │ │
|
||
│ связь/батарея/ │ │ │ • IActivityTaskMgr │ │
|
||
│ состояние │ │ │ .startActivity │ │
|
||
│ │ │ │ AsUser(...) │ │
|
||
└─────────────────────┘ │ └────────┬───────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ VirtualDisplay 640×480 │
|
||
│ ┌────────────────────┐ │
|
||
│ │ free.zona │ │
|
||
│ │ (кинотеатр) │ │
|
||
│ └────────────────────┘ │
|
||
│ │ │
|
||
│ Surface кадров │
|
||
│ ▼ │
|
||
│ binocular-renderer │
|
||
│ (текстура → 2 копии │
|
||
│ в 1280×480) │
|
||
└──────────────────────────┘
|
||
│
|
||
Оптика очков: делит
|
||
1280×480 на 2 × 640×480
|
||
(L + R)
|
||
```
|
||
|
||
### 3.1. Поток данных
|
||
|
||
1. Пользователь на телефоне тапает по тачпаду → PhoneApp формирует событие.
|
||
2. PhoneApp отправляет событие по WebSocket-каналу в GlassesApp.
|
||
3. GlassesApp пересылает событие в VM-сервис через `InputManager.injectInputEvent(...)` — событие попадает в активное приложение (`free.zona`) на VirtualDisplay.
|
||
4. `free.zona` рисует кадр в Surface виртуального дисплея.
|
||
5. Кадр через `SurfaceTexture` → GL-текстуру → binocular-renderer → 2 копии в `1280×480` физический дисплей очков.
|
||
6. Оптика очков раздаёт левую половину на левый глаз, правую — на правый.
|
||
|
||
---
|
||
|
||
## 4. Функциональные требования
|
||
|
||
### 4.1. Подмодуль `app-glasses-vm` (GlassesApp)
|
||
|
||
- Устанавливается на очки (Android 12, API 32, arm64-v8a).
|
||
- Запускается в landscape на `displayId=0` (1280×480).
|
||
- **UI на Jetpack Compose** (как PhoneApp — единый стек). GL-рендер для видео встроен через `AndroidView { SurfaceView(...) }`.
|
||
- Использует binocular-renderer из `:binocular-renderer` для отрисовки кадров VirtualDisplay.
|
||
- Поднимает VM-сервис (Shizuku UserService).
|
||
- Запускает канал связи (WebSocket-сервер на очках, ждёт подключения от PhoneApp).
|
||
- Получает от PhoneApp события управления и отправляет их через `InputManager.injectInputEvent` в активное приложение на VirtualDisplay.
|
||
|
||
**Структура Compose-дерева:**
|
||
|
||
```kotlin
|
||
@Composable
|
||
fun GlassesScreen(controller: VmGlassesController, modifier: Modifier = Modifier) {
|
||
val state by controller.screenState.collectAsStateWithLifecycle()
|
||
Box(modifier = modifier.fillMaxSize().background(Color.Black)) {
|
||
// Слой 1: GL рендер кадров VirtualDisplay (для состояния Running)
|
||
if (state is ScreenState.Running) {
|
||
BinocularRendererView(
|
||
frameSource = controller.frameSource,
|
||
modifier = Modifier.fillMaxSize(),
|
||
)
|
||
}
|
||
// Слой 2: Compose overlay (поверх GL или вместо него)
|
||
when (state) {
|
||
is ScreenState.Starting -> LoadingOverlay("Загрузка...")
|
||
is ScreenState.ShizukuMissing -> MessageOverlay("Shizuku не активен. Запустите на телефоне и перезапустите приложение.")
|
||
is ScreenState.NoAppSelected -> MessageOverlay("Откройте приложение на телефоне")
|
||
is ScreenState.Running -> if (state.overlay == OverlayKind.NoConnection) NoConnectionOverlay()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**`BinocularRendererView`** — `@Composable` обёртка из rayneo-mercury `:binocular-renderer`:
|
||
|
||
```kotlin
|
||
@Composable
|
||
fun BinocularRendererView(
|
||
frameSource: FrameSource,
|
||
modifier: Modifier = Modifier,
|
||
) {
|
||
val renderer = remember(frameSource) { BinocularEglRenderer(frameSource) }
|
||
AndroidView(
|
||
factory = { ctx ->
|
||
SurfaceView(ctx).also { sv ->
|
||
sv.holder.addCallback(renderer.createCallback())
|
||
}
|
||
},
|
||
modifier = modifier,
|
||
)
|
||
}
|
||
```
|
||
|
||
Внутри `BinocularEglRenderer` — EGL-стек: создание GL-контекста, шейдеры, обновление текстуры из `SurfaceTexture` (подключенного к Surface виртуального дисплея), отрисовка **в две половины экрана 1280×480** одним drawcall.
|
||
|
||
### 4.2. Binocular-renderer (общий модуль в rayneo-mercury)
|
||
|
||
- **Публичный API:** `@Composable fun BinocularRendererView(frameSource: FrameSource, modifier: Modifier = Modifier)` — встраивается в любое Compose-дерево через `AndroidView { SurfaceView }`.
|
||
- **Внутри:** `BinocularEglRenderer` — класс с EGL-контекстом, шейдерами, текстурой.
|
||
- Делит экран **строго по вертикали пополам** одним drawcall:
|
||
- Левая половина (x ∈ [0, w/2]) → левый глаз.
|
||
- Правая половина (x ∈ [w/2, w]) → правый глаз.
|
||
- В обеих половинах рисует **одно и то же изображение** (текущий кадр из источника), без искажений и цветокоррекции.
|
||
- Ориентация: фиксированный landscape.
|
||
- **Источник кадров** — абстракция (интерфейс `FrameSource`):
|
||
- `VideoFrameSource` — для view-mate (берёт кадры из ExoPlayer).
|
||
- `SurfaceTextureFrameSource` — для rayneo-vm (берёт кадры из `SurfaceTexture`, подключённого к VirtualDisplay).
|
||
|
||
> Это и есть тот самый «один модуль на двоих»: один рендерер, разные источники. GL-стек одинаковый, разница только в том, откуда берутся кадры.
|
||
|
||
### 4.3. VirtualDisplay 640×480
|
||
|
||
- Создаётся в **VM-сервисе** через `IDisplayManager.createVirtualDisplay(...)` **без** `VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY`.
|
||
- Параметры:
|
||
- Размер: 640×480, фиксированно.
|
||
- Ориентация: landscape.
|
||
- Density: 160 dpi (комфортно для `free.zona`).
|
||
- Display виден всей системе, в т.ч. `free.zona` может на нём запуститься.
|
||
- Жизненный цикл: создаётся в VM-сервисе по команде из GlassesApp, освобождается при остановке.
|
||
|
||
### 4.4. Запуск гостевого приложения
|
||
|
||
- В VM-сервисе через `IActivityTaskManager.startActivityAsUser(...)`:
|
||
- `ActivityOptions.setLaunchDisplayId(virtualDisplayId)` — целевой display.
|
||
- Компонент `free.zona/ru.zona.app.android.MainActivity`.
|
||
- `UserHandle.SYSTEM` или `UserHandle.CURRENT` — уточнить на этапе разработки.
|
||
|
||
### 4.5. Захват кадров с VirtualDisplay
|
||
|
||
- `IDisplayManager.createVirtualDisplay(...)` возвращает **Surface**, в который система рисует кадры `free.zona`.
|
||
- Этот Surface пробрасываем из VM-сервиса в GlassesApp (по AIDL-биндеру, как `Parcelable`-обёртку или через `SurfaceTexture`).
|
||
- В GlassesApp подключаем к Surface `SurfaceTexture` и через него обновляем GL-текстуру в binocular-renderer.
|
||
|
||
### 4.6. Подмодуль `app-phone-vm` (PhoneApp)
|
||
|
||
**Общая структура UI** (Compose Navigation):
|
||
|
||
```
|
||
HomeScreen
|
||
├── таб "Очки" → GlassesStatusScreen (батарея, память, текущее приложение — как в view-mate)
|
||
└── таб "Приложения" → AppsListScreen (список AppProfile'ов)
|
||
└── тап на приложение → AppControlScreen(profile = ...) (специфичный UI)
|
||
```
|
||
|
||
#### 4.6.1. Профили приложений (`AppProfile`)
|
||
|
||
У каждого приложения — свой UI управления (тачпад + специфичные кнопки). Общий механизм — интерфейс `AppProfile` в `:app-phone-vm`:
|
||
|
||
```kotlin
|
||
interface AppProfile {
|
||
val packageName: String // "free.zona"
|
||
val displayName: String // "Zona"
|
||
@Composable
|
||
fun Controls(
|
||
protocol: VmProtocol,
|
||
modifier: Modifier = Modifier,
|
||
)
|
||
}
|
||
|
||
object ZonaProfile : AppProfile {
|
||
override val packageName = "free.zona"
|
||
override val displayName = "Zona"
|
||
@Composable
|
||
override fun Controls(protocol: VmProtocol, modifier: Modifier) {
|
||
// тачпад + медиа-кнопки + клавиатура + назад — см. ниже
|
||
}
|
||
}
|
||
|
||
// Будущее:
|
||
// object JellyfinProfile : AppProfile { ... }
|
||
|
||
val profiles: List<AppProfile> = listOf(ZonaProfile /*, JellyfinProfile */)
|
||
```
|
||
|
||
#### 4.6.2. ZonaProfile.Controls
|
||
|
||
```kotlin
|
||
@Composable
|
||
override fun Controls(protocol: VmProtocol, modifier: Modifier) {
|
||
var showKeyboard by remember { mutableStateOf(false) }
|
||
Column(modifier) {
|
||
TouchpadView(
|
||
onTouch = { actions -> protocol.send(TouchEvent(actions)) },
|
||
modifier = Modifier.weight(1f), // тачпад во всё доступное место
|
||
)
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
MediaButton("⏪") { protocol.send(KeyEvent(KeyEvent.KEYCODE_MEDIA_REWIND, DOWN)) }
|
||
MediaButton("⏯") { protocol.send(KeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, DOWN)) }
|
||
MediaButton("⏩") { protocol.send(KeyEvent(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, DOWN)) }
|
||
MediaButton("⌨") { showKeyboard = true }
|
||
MediaButton("←") { protocol.send(KeyEvent(KeyEvent.KEYCODE_BACK, DOWN)) }
|
||
}
|
||
}
|
||
if (showKeyboard) KeyboardDialog(onDismiss = { showKeyboard = false }) { text ->
|
||
// Каждый символ → KeyEvent с KEYCODE_* в Zona
|
||
for (ch in text) protocol.send(KeyEvent(KeyEvent.keyCodeForChar(ch), DOWN))
|
||
}
|
||
}
|
||
```
|
||
|
||
**Кнопки:**
|
||
- ⏪ `KEYCODE_MEDIA_REWIND` — перемотка назад
|
||
- ⏯ `KEYCODE_MEDIA_PLAY_PAUSE` — плей/пауза
|
||
- ⏩ `KEYCODE_MEDIA_FAST_FORWARD` — перемотка вперёд
|
||
- ⌨ — открывает диалог клавиатуры
|
||
- ← `KEYCODE_BACK` — назад
|
||
|
||
Все команды отправляются как обычные `KeyEvent` (DTO уже умеет любые `keyCode`). Прибывают на очки → `injectKeyEvent` через VM-сервис в активное приложение.
|
||
|
||
#### 4.6.3. Клавиатура — диалог с полем ввода
|
||
|
||
Всплывающее окно с `OutlinedTextField` и кнопкой «Отправить»:
|
||
|
||
```kotlin
|
||
@Composable
|
||
fun KeyboardDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) {
|
||
var text by remember { mutableStateOf("") }
|
||
AlertDialog(
|
||
onDismissRequest = onDismiss,
|
||
title = { Text("Ввод текста") },
|
||
text = { OutlinedTextField(value = text, onValueChange = { text = it }, singleLine = false) },
|
||
confirmButton = { Button(onClick = { onSubmit(text); onDismiss() }) { Text("Отправить") } },
|
||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||
)
|
||
}
|
||
```
|
||
|
||
При «Отправить» каждый символ строки → `KeyEvent` с `keyCode = KeyEvent.keyCodeForChar(ch)` → на очки → `injectKeyEvent`. В Zona текст появляется посимвольно (так работает любая виртуальная клавиатура).
|
||
|
||
> **Почему не системная клавиатура:** системная требует управления фокусом Compose + серьёзные проблемы с `WindowInsets`/`ImeAction` в Compose. Поле ввода с кнопкой «Отправить» проще, надёжнее, и UX в данном случае даже удобнее (юзер видит весь ввод перед отправкой).
|
||
|
||
#### 4.6.4. Тачпад
|
||
|
||
`TouchpadView` — Compose-обёртка над `Box.onTouchEvent`. Маппинг координат: тачпад во весь экран → координаты 0..640 × 0..480 (разрешение VirtualDisplay). Отправляет серии `TouchEvent(actions=[down, move, move, up])`.
|
||
|
||
#### 4.6.5. Запуск приложения
|
||
|
||
**Запуск GlassesApp на очках** (через Mercury-канал):
|
||
|
||
1. PhoneApp обнаруживает, что WebSocket-канал не поднялся (mDNS discovery не нашёл наш сервис).
|
||
2. На `GlassesStatusScreen` показывается кнопка «▶ Запустить наше приложение на очках».
|
||
3. По нажатию — `MercuryBridge.launchApp("pw.binom.rayneovm.glasses")` через BLE-протокол RayNeo.
|
||
4. Очки сами открывают наше приложение (`Intent.PACKAGE_LAUNCH` по пакету).
|
||
5. GlassesApp стартует → поднимает WebSocket-сервер → mDNS publish → PhoneApp находит → подключается.
|
||
|
||
**Запуск гостевого приложения (Zona)** (через WebSocket-канал):
|
||
|
||
1. При тапе на профиль в `AppsListScreen` PhoneApp шлёт `LaunchAppCommand(packageName="free.zona")` через `VmProtocol` (WebSocket).
|
||
2. GlassesApp получает → VM-сервис запускает/фокусирует Activity (`IActivityTaskManager.startActivityAsUser` + `setLaunchDisplayId`).
|
||
3. Если приложение уже запущено — просто фокусируем.
|
||
4. Если не запущено — стартуем.
|
||
5. PhoneApp переходит на `AppControlScreen(profile)`.
|
||
|
||
#### 4.6.6. Статус очков (таб «Очки»)
|
||
|
||
**Два независимых канала связи с очками:**
|
||
|
||
| Канал | Технология | Назначение |
|
||
|---|---|---|
|
||
| **Mercury-канал** (служебный) | BLE, фирменный протокол RayNeo Mercury (`pw.binom.mercury:library`) | Спаривание, статус очков (батарея/яркость/WiFi), **запуск нашего GlassesApp по пакету** через `appMarketLaunch` |
|
||
| **WebSocket-канал** (основной) | WiFi, наш `:transport` модуль | Управление запущенным GlassesApp: тачи, клавиши, команды Zona |
|
||
|
||
Mercury-канал доступен всегда (когда очки спарены), даже если наше GlassesApp ещё не запущено. Используем его для:
|
||
1. Проверки, что очки вообще доступны.
|
||
2. Получения батареи/статуса.
|
||
3. **Запуска GlassesApp одной кнопкой** (`MercuryBridge.launchApp("pw.binom.rayneovm.glasses")` — очки сами открывают наш пакет).
|
||
4. Гашения/включения экрана очков, управления WiFi очков, запрета засыпания (heartbeat).
|
||
|
||
**Экран `GlassesStatusScreen`:**
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ Очки │
|
||
│ │
|
||
│ Bluetooth-соединение: ✓ подключено │
|
||
│ Батарея очков: 87% │
|
||
│ Яркость экрана: 60% │
|
||
│ WiFi очков: ✓ SSID "HomeNet" │
|
||
│ │
|
||
│ Наше приложение на очках: ✗ не запущено │ ← определяем через mDNS discovery
|
||
│ │
|
||
│ [▶ Запустить наше приложение на очках] │ ← кнопка видна если не запущено
|
||
│ │
|
||
│ --- WebSocket-канал --- │
|
||
│ Связь с приложением: ✓ / ✗ │ ← через mDNS / WebSocket status
|
||
│ Текущее приложение: Zona │ ← из GlassesStatus.currentApp
|
||
└──────────────────────────────────────────┘
|
||
```
|
||
|
||
**ViewModel `GlassesStatusViewModel`** держит:
|
||
- `mercuryConnected: StateFlow<Boolean>`
|
||
- `glassesBattery: StateFlow<Int?>`
|
||
- `glassesBrightness: StateFlow<Int?>`
|
||
- `glassesWifi: StateFlow<WifiInfo?>`
|
||
- `appLaunchedOnGlasses: StateFlow<Boolean>` — обновляется через mDNS-сканирование каждые 5с
|
||
- `wsConnected: StateFlow<Boolean>` — состояние WebSocket-канала
|
||
|
||
#### 4.6.7. UI design choices (зафиксировано)
|
||
|
||
| Параметр | Решение |
|
||
|---|---|
|
||
| UI-фреймворк | **Jetpack Compose + Material 3** |
|
||
| Тема | **Только тёмная** (минимум кода, типично для кино-приложения) |
|
||
| Навигация | **Bottom navigation bar** (Material 3 `NavigationBar`), табы: «Очки» / «Приложения» |
|
||
| Тачпад визуал | Серый/тёмный фон + точка в месте касания (визуальная обратная связь) |
|
||
| Архитектура UI | **MVVM**: Composable + ViewModel + StateFlow (через `collectAsStateWithLifecycle`) |
|
||
|
||
#### 4.6.8. Shizuku UX
|
||
|
||
При запуске GlassesApp проверяем Shizuku:
|
||
- **Если Shizuku активен** — работаем штатно.
|
||
- **Если Shizuku не активен** — полноэкранный блок с объяснением:
|
||
- Что такое Shizuku, зачем он нужен.
|
||
- Как установить (`market://...` или ссылка на сайт rikka.app).
|
||
- Как активировать в ADB-режиме.
|
||
- Кнопка «Открыть настройки Shizuku» (intent на `moe.shizuku.manager`).
|
||
- При появлении Shizuku (через listener `Shizuku.addBinderReceivedListener`) — автоматически убираем блок.
|
||
|
||
#### 4.6.9. Потеря связи GlassesApp ↔ PhoneApp
|
||
|
||
- WebSocket-обрыв → авто-реконнект в фоне с экспоненциальным backoff: 1с → 2с → 4с → 8с → 16с → 30с (макс).
|
||
- Параллельно — Snackbar «Связь потеряна, переподключение...».
|
||
- При восстановлении — Snackbar «Связь восстановлена».
|
||
- При восстановлении — `VmGlassesController` синхронизирует состояние (какое приложение запущено, режим звука и т.п.).
|
||
|
||
#### 4.6.10. Архитектура UI (MVVM)
|
||
|
||
Каждый экран = Composable + ViewModel. ViewModel держит `StateFlow<ScreenState>`, Composable подписывается через `collectAsStateWithLifecycle()`.
|
||
|
||
```
|
||
PhoneApp/
|
||
├── ui/
|
||
│ ├── home/
|
||
│ │ ├── HomeScreen.kt # корневой экран с NavigationBar
|
||
│ │ ├── HomeViewModel.kt
|
||
│ │ └── HomeState.kt
|
||
│ ├── glasses_status/
|
||
│ │ ├── GlassesStatusScreen.kt
|
||
│ │ ├── GlassesStatusViewModel.kt
|
||
│ │ └── GlassesStatusState.kt
|
||
│ ├── apps_list/
|
||
│ │ ├── AppsListScreen.kt
|
||
│ │ ├── AppsListViewModel.kt
|
||
│ │ └── AppsListState.kt
|
||
│ ├── app_control/
|
||
│ │ ├── AppControlScreen.kt # обёртка, выбирает AppProfile.Controls
|
||
│ │ ├── AppControlViewModel.kt
|
||
│ │ └── AppControlState.kt
|
||
│ ├── profiles/
|
||
│ │ ├── AppProfile.kt # интерфейс
|
||
│ │ └── ZonaProfile.kt
|
||
│ ├── components/
|
||
│ │ ├── TouchpadView.kt
|
||
│ │ ├── MediaButton.kt
|
||
│ │ ├── KeyboardDialog.kt
|
||
│ │ └── ConnectionStatus.kt
|
||
│ └── theme/
|
||
│ ├── Theme.kt # Material 3, только dark
|
||
│ ├── Color.kt
|
||
│ └── Type.kt
|
||
└── ...
|
||
```
|
||
|
||
#### 4.6.11. Обработка краша гостевого приложения
|
||
|
||
**Что происходит, когда гостевое приложение (например, `free.zona`) падает:**
|
||
|
||
| Что упало | Что остаётся | Что пропадает |
|
||
|---|---|---|
|
||
| Activity (unhandled exception) | VirtualDisplay, Surface, процесс | Activity мертва, нужно перезапустить |
|
||
| Процесс приложения (OOM killer, SIGKILL) | VirtualDisplay, Surface (рисует чёрный/последний кадр) | Процесс мёртв |
|
||
| Наш VM-сервис | — | VirtualDisplay + Surface исчезают |
|
||
| Наш GlassesApp | VM-сервис живёт в отдельном процессе | WebSocket-сервер падает |
|
||
|
||
**Главное:** `VirtualDisplay` привязан к нашему VM-сервису (нашему процессу), а не к процессу гостя. VirtualDisplay остаётся живой после падения гостя — мы просто перезапускаем Activity на ней.
|
||
|
||
**Детектирование:** в VM-сервисе подписываемся на `IActivityTaskManager.registerTaskStackListener(...)` (доступно через Shizuku). На каждый `onTaskDestroyed(taskInfo)` проверяем `taskInfo.baseActivity.packageName` — если это гостевое приложение, шлём событие в PhoneApp.
|
||
|
||
**Без автоперезапуска** (по решению пользователя):
|
||
|
||
1. VM-сервис получает `onTaskDestroyed` для пакета `free.zona`.
|
||
2. Шлёт `AppCrashedEvent(packageName)` через WebSocket в PhoneApp.
|
||
3. `AppControlViewModel` ловит событие:
|
||
- `Snackbar` «Zona упала»
|
||
- `navController.popBackStack()` → возврат на `AppsListScreen`
|
||
4. Пользователь сам решает — тапает на Zona снова или нет.
|
||
|
||
**Без лимита рестартов** (раз нет автоперезапуска, лимит не нужен).
|
||
|
||
**DTO:**
|
||
```kotlin
|
||
@Serializable @SerialName("app_crashed")
|
||
data class AppCrashedEvent(val packageName: String) : VmMessage
|
||
```
|
||
|
||
**Edge cases (не покрываем на этой ночи, отложено):**
|
||
- VM-сервис упал → детектим через `DeadObjectException` на клиенте, перезапуск сервиса.
|
||
- GlassesApp упал → WebSocket обрыв → уже покрыто авто-реконнектом + Snackbar (раздел 4.6.9).
|
||
- Zona падает в цикле при ручном рестарте → пользователь сам разбирается (видит, что падает).
|
||
|
||
#### 4.6.12. Состояния экрана очков (GlassesApp рендерит)
|
||
|
||
GlassesApp — это не «прозрачный пайплайн», у него есть **свой overlay-режим** для случаев, когда показывать VirtualDisplay не нужно или он недоступен. Реализуется через `BinocularRenderer` с переключением между двумя `FrameSource`'ами: `VirtualDisplayFrameSource` (основной) и `OverlayFrameSource` (для надписей и заглушек).
|
||
|
||
**Состояние 1: «Всё работает»** (VirtualDisplay есть, PhoneApp подключен)
|
||
- Источник: `VirtualDisplayFrameSource`
|
||
- Что видно: живой кадр из Zona (или любого запущенного приложения).
|
||
|
||
**Состояние 2: «Нет связи с PhoneApp»** (VirtualDisplay есть, WebSocket-канал оборван >5с)
|
||
- Источник: `OverlayFrameSource` поверх `VirtualDisplayFrameSource`
|
||
- Что видно: последний кадр VirtualDisplay + полупрозрачная надпись «⚠ Нет связи» по центру.
|
||
- Когда связь восстановилась → возвращаемся к состоянию 1.
|
||
- Детектирование: WebSocket `onDisconnected` срабатывает, через 5с показываем overlay. При `onConnected` — убираем.
|
||
|
||
**Состояние 3: «Приложение не выбрано»** (VirtualDisplay есть, но гостевое приложение не запущено)
|
||
- Источник: `OverlayFrameSource` (полностью перекрывает VirtualDisplay)
|
||
- Что видно: чёрный фон + текст по центру «Откройте приложение на телефоне».
|
||
|
||
**Состояние 4: «Shizuku не активен»** (GlassesApp стартует, Shizuku не разрешает)
|
||
- Источник: `OverlayFrameSource`
|
||
- Что видно: чёрный фон + текст «Shizuku не активен. Запустите Shizuku на телефоне и перезапустите приложение». (Телефон отдельно показывает инструкцию в своём UI.)
|
||
|
||
**Состояние 5: «Стартует»** (GlassesApp запустился, ещё ничего не инициализировано)
|
||
- Источник: `OverlayFrameSource`
|
||
- Что видно: чёрный фон + логотип/название «RayNeo VM» + текст «Загрузка...».
|
||
|
||
**Управление состоянием:** `VmGlassesController` держит `StateFlow<ScreenState>`, рендерер подписывается и переключает `FrameSource` при изменении.
|
||
|
||
```
|
||
sealed interface ScreenState {
|
||
object Starting : ScreenState
|
||
object ShizukuMissing : ScreenState
|
||
object NoAppSelected : ScreenState
|
||
data class Running(val overlay: OverlayKind = OverlayKind.None) : ScreenState
|
||
}
|
||
|
||
enum class OverlayKind { None, NoConnection }
|
||
```
|
||
|
||
### 4.7. Канал связи и протокол
|
||
|
||
#### 4.7.1. Разделение ответственности
|
||
|
||
> **Транспорт vs протокол — это разные вещи.**
|
||
> - **Транспорт** (модуль `:transport` в `rayneo-mercury`) решает **КАК** передавать байты/строки: WebSocket, BT, реконнект, discovery. Не знает про домен.
|
||
> - **Протокол** (в коде rayneo-vm) решает **ЧТО** передавать: DTO, формат (JSON / protobuf / что угодно), семантика событий.
|
||
|
||
Это позволяет переиспользовать `:transport` где угодно (другие приложения, другие домены), не копируя код.
|
||
|
||
#### 4.7.2. Канал связи (из `:transport`)
|
||
|
||
- **WiFi (основной):** WebSocket через Ktor. Телефон поднимает `WifiServerTransport`, очки — `WifiTransport`-клиент. Discovery через `MdnsScanner` (+ fallback скан /24, потому что на RayNeo multicast режется).
|
||
- **Bluetooth (fallback):** классический Bluetooth SPP / BLE GATT. `BtServerTransport` на телефоне, `BtTransport` на очках.
|
||
- **Автоматика:** авто-реконнект при потере связи, индикация статуса.
|
||
- Кто инициирует соединение: **телефон → очки**. Телефон публикует себя (mDNS / BT discoverable), очки находят и подключаются.
|
||
|
||
#### 4.7.3. Протокол rayneo-vm (НЕ в либе, в коде приложения)
|
||
|
||
Свои DTO, своя сериализация — живут в `app-glasses-vm` / `app-phone-vm`:
|
||
|
||
```kotlin
|
||
// от Phone → Glasses: тап/свайп по тачпаду
|
||
@Serializable data class TouchEvent(
|
||
val displayId: Int,
|
||
val actions: List<TouchAction> // серия down/move/up с координатами 0..640, 0..480
|
||
)
|
||
|
||
// от Phone → Glasses: клавиша
|
||
@Serializable data class KeyEvent(
|
||
val keyCode: Int,
|
||
val action: KeyAction // DOWN / UP
|
||
)
|
||
|
||
// от Glasses → Phone: статус (батарея, текущее приложение, состояние)
|
||
@Serializable data class GlassesStatus(
|
||
val batteryPercent: Int?,
|
||
val currentApp: String?,
|
||
val connectionOk: Boolean,
|
||
val displayId: Int
|
||
)
|
||
```
|
||
|
||
Формат — JSON (через kotlinx-serialization). Транспорт `:transport` передаёт их как `String` через `StringTransport.send(...)`.
|
||
|
||
### 4.8. Управление / инпут в гостевое приложение
|
||
|
||
- PhoneApp → GlassesApp → VM-сервис → `InputManager.injectInputEvent(...)` → активное приложение на VirtualDisplay.
|
||
- Координаты тачпада на телефоне мапятся в координаты VirtualDisplay (640×480).
|
||
- `injectInputEvent` требует `INJECT_EVENTS` permission — даём через Shizuku UserService (см. 8.4.4).
|
||
- Опционально — long-press для контекстного меню, swipe для скролла, multi-touch (pinch-to-zoom, если Zona поддерживает).
|
||
|
||
### 4.9. Интеграция с rayneo-mercury
|
||
|
||
| Наш модуль | Зависимость от rayneo-mercury |
|
||
|---|---|
|
||
| `app-glasses-vm` | `:binocular-renderer` (для рендера), `:transport` (для WebSocket-сервера, mDNS-сервера), `:shizuku-vd` (для UserService) |
|
||
| `app-phone-vm` | `:transport` (для WebSocket-клиента, mDNS-клиента), `:library` (для Mercury BLE — спаривание, статус, **запуск GlassesApp**) |
|
||
|
||
Подключение через Maven-координаты `pw.binom.mercury:<module>:<version>`, репо — локальный Nexus `http://192.168.76.117/repository/caffeine/`. Креды — в `~/.gradle/gradle.properties` (уже настроено для существующего `rayneo-mercury:library`).
|
||
|
||
---
|
||
|
||
## 4.10. Структура приложения rayneo-vm
|
||
|
||
### 4.10.1. Gradle-модули
|
||
|
||
```
|
||
rayneo-vm/
|
||
├── settings.gradle.kts # include :app-glasses-vm, :app-phone-vm, :shared
|
||
├── build.gradle.kts # root
|
||
├── gradle.properties
|
||
├── docs/SPEC.md # этот документ
|
||
│
|
||
├── shared/ # общий код между двумя приложениями (НЕ в rayneo-mercury)
|
||
│ ├── build.gradle.kts # плагин kotlinx-serialization, без зависимостей от rayneo-mercury
|
||
│ └── src/commonMain/kotlin/pw/binom/rayneovm/shared/
|
||
│ ├── VmMessage.kt # sealed interface VmMessage — базовый тип
|
||
│ ├── TouchEvent.kt # @Serializable @SerialName("touch") data class TouchEvent(...)
|
||
│ ├── TouchAction.kt # ACTION_DOWN/MOVE/UP + координаты 0..640, 0..480
|
||
│ ├── KeyEvent.kt # @Serializable @SerialName("key") data class KeyEvent(...)
|
||
│ ├── KeyAction.kt # DOWN/UP + keyCode
|
||
│ └── GlassesStatus.kt # @Serializable @SerialName("status") data class GlassesStatus(...)
|
||
│
|
||
├── app-glasses-vm/ # приложение для очков RayNeo X3 Pro
|
||
│ ├── build.gradle.kts # зависимости: :shared, rayneo-mercury:binocular-renderer,
|
||
│ │ # rayneo-mercury:transport, rayneo-mercury:shizuku-vd,
|
||
│ │ # dev.rikka.shizuku:api, RayNeo X3 Pro SDK
|
||
│ └── src/main/...
|
||
│
|
||
└── app-phone-vm/ # приложение для телефона
|
||
├── build.gradle.kts # зависимости: :shared, rayneo-mercury:transport, Compose, Activity
|
||
└── src/main/...
|
||
```
|
||
|
||
> **Важно:** `:shared` — это модуль **внутри** rayneo-vm, не в `rayneo-mercury`. DTO не публикуем, не шарим между проектами. Просто общий код двух приложений одного проекта.
|
||
|
||
### 4.10.2. Классы в `app-glasses-vm`
|
||
|
||
| Класс | Ответственность |
|
||
|---|---|
|
||
| `VmGlassesApp : Application` | Инициализация зависимостей, регистрация Shizuku permission listener, запуск `VmService` через `startForegroundService` в `onCreate`. |
|
||
| `MainActivity : ComponentActivity` | Compose-host. Ставит fullscreen + immersive, рендерит `GlassesScreen(controller)`. Управляет bindService на `VmService`. |
|
||
| `VmService : LifecycleService` | **Foreground service** в процессе GlassesApp. Внутри: WiFi-сервер (WebSocket через `:transport`), mDNS-публикация, `VmProtocol`. Service устойчив к lifecycle Activity — сервер и mDNS живут пока Service foreground. |
|
||
| `VmGlassesController` | Оркестратор. Держит ссылки на Service, рендер, протокол. Управляет состоянием экрана (`ScreenState` flow). |
|
||
| `VirtualDisplayFrameSource : FrameSource` | Реализация интерфейса из `:binocular-renderer`. Принимает `SurfaceTexture` (получен из VM-сервиса), обновляет GL-текстуру для каждого кадра. |
|
||
| `OverlayFrameSource : FrameSource` | Рендерит Compose-overlay (текст, статус) вместо живого видео для состояний `Starting`, `ShizukuMissing`, `NoAppSelected`, `NoConnection`. |
|
||
| `VmProtocol` | Обёртка над `StringTransport` (из `:transport`). Декодит входящие строки в `VmMessage`, кодит исходящие. |
|
||
| `VmShizukuService` | Клиент к Shizuku UserService. Содержит AIDL-биндер, через который дёргает методы сервиса. |
|
||
| `VmShizukuUserService : UserService` | Реализация UserService (запускается в отдельном процессе с правами shell). В нём: создание VirtualDisplay, запуск `free.zona`, проброс Surface обратно. |
|
||
|
||
**Lifecycle VmService:**
|
||
- `onCreate`: создаёт Ktor `CIO` server engine, регистрирует WebSocket endpoint `/ws/rayneo-vm`, запускает mDNS publisher (`_rayneo-vm._tcp.local`).
|
||
- `onStartCommand`: `startForeground(NOTIFICATION_ID, notification)` — держит себя foreground.
|
||
- `onDestroy`: останавливает сервер, отменяет mDNS.
|
||
- Service не пересоздаётся системой (foreground).
|
||
|
||
**Архитектурно:** WebSocket и mDNS живут в **нашем процессе** (GlassesApp), а системные вызовы — в **отдельном процессе** (Shizuku UserService). Связь между ними через AIDL-биндер (тот же `VmShizukuService`). Это даёт: GlassesApp можно убить/перезапустить без потери Shizuku-сессии (но WebSocket перезапустится вместе с ним).
|
||
|
||
### 4.10.3. Классы в `app-phone-vm`
|
||
|
||
| Класс | Ответственность |
|
||
|---|---|
|
||
| `VmPhoneApp : Application` | Инициализация зависимостей, инициализация MercuryBridge. |
|
||
| `MainActivity : ComponentActivity` | Compose-host. Ставит полноэкранный режим, рендерит `HomeNavGraph`. |
|
||
| `VmPhoneController` | Оркестратор. Транспорт (WiFi-клиент через `:transport`), discovery (mDNS/BT scan), протокол, реконнект-логика. Lifecycle. |
|
||
| `MercuryBridge` | Обёртка над `pw.binom.mercury:library` для BLE-канала: спаривание, статус очков, **запуск GlassesApp по пакету** через `appMarketLaunch`. |
|
||
| `HomeNavGraph` | Compose NavHost. Табы: «Очки» (GlassesStatusScreen) + «Приложения» (AppsListScreen → AppControlScreen). |
|
||
| `GlassesStatusScreen : @Composable` | Таб «Очки»: BT-соединение, батарея, статус нашего GlassesApp (через mDNS), кнопка «▶ Запустить наше приложение на очках». |
|
||
| `GlassesStatusViewModel` | StateFlow с состоянием Mercury + mDNS discovery + WebSocket. |
|
||
| `AppsListScreen : @Composable` | Таб «Приложения»: список профилей (ZonaProfile и т.д.). |
|
||
| `AppControlScreen : @Composable` | Обёртка, рендерит `AppProfile.Controls` выбранного профиля. |
|
||
| `AppProfile` (interface) | `packageName`, `displayName`, `@Composable Controls(protocol: VmProtocol, modifier)`. |
|
||
| `ZonaProfile : AppProfile` | Touchpad + media buttons + keyboard dialog + back. |
|
||
| `KeyboardDialog` | Диалог с OutlinedTextField + «Отправить» — каждый символ → `KeyEvent(keyCodeForChar(ch))`. **Не системная клавиатура** (избегаем focus/WindowInsets pain в Compose). |
|
||
| `TouchpadController` | Логика тачпада: накапливает тачи, формирует `TouchEvent`, шлёт в `VmProtocol`. |
|
||
| `VmProtocol` | Обёртка над `StringTransport`. Кодит/декодит `VmMessage`. |
|
||
|
||
### 4.10.3.1. Конкретные DTO `:shared` модуля
|
||
|
||
```kotlin
|
||
// Базовый sealed interface для всех сообщений
|
||
@Serializable
|
||
sealed interface VmMessage
|
||
|
||
// === PhoneApp → GlassesApp ===
|
||
|
||
@Serializable @SerialName("touch")
|
||
data class TouchEvent(val actions: List<TouchAction>) : VmMessage
|
||
|
||
@Serializable
|
||
data class TouchAction(
|
||
val type: TouchType, // DOWN, MOVE, UP
|
||
val x: Float, // 0..640
|
||
val y: Float, // 0..480
|
||
)
|
||
|
||
@Serializable @SerialName("key")
|
||
data class KeyEvent(val keyCode: Int) : VmMessage
|
||
|
||
@Serializable @SerialName("launch_app")
|
||
data class LaunchAppCommand(val packageName: String) : VmMessage
|
||
|
||
@Serializable @SerialName("set_audio_mode")
|
||
data class SetAudioMode(val mode: AudioMode) : VmMessage // ON_GLASSES, ON_PHONE
|
||
|
||
@Serializable @SerialName("shutdown")
|
||
object Shutdown : VmMessage // закрыть гостевое приложение
|
||
|
||
// === GlassesApp → PhoneApp ===
|
||
|
||
@Serializable @SerialName("glasses_status")
|
||
data class GlassesStatus(
|
||
val batteryPercent: Int? = null,
|
||
val wifiSsid: String? = null,
|
||
val currentApp: String? = null, // package name или null
|
||
val running: Boolean = false,
|
||
) : VmMessage
|
||
|
||
@Serializable @SerialName("app_crashed")
|
||
data class AppCrashedEvent(val packageName: String) : VmMessage
|
||
|
||
@Serializable @SerialName("app_launched_ack")
|
||
data class AppLaunchedAck(val packageName: String, val success: Boolean) : VmMessage
|
||
|
||
// === Общие ===
|
||
|
||
@Serializable @SerialName("ping")
|
||
object Ping : VmMessage
|
||
|
||
@Serializable @SerialName("pong")
|
||
object Pong : VmMessage
|
||
|
||
// === Enums ===
|
||
|
||
@Serializable
|
||
enum class TouchType { DOWN, MOVE, UP }
|
||
|
||
@Serializable
|
||
enum class AudioMode { ON_GLASSES, ON_PHONE }
|
||
```
|
||
|
||
### 4.10.3.2. Тестовая стратегия
|
||
|
||
**Фреймворки:** JUnit5 (`org.junit.jupiter`) + MockK + Turbine + kotlinx-coroutines-test.
|
||
|
||
**Что покрываем unit-тестами (JVM, без Android):**
|
||
- `VmProtocol` — roundtrip encode/decode всех DTO.
|
||
- `TouchpadController` — маппинг touch-coordinates, батчинг событий.
|
||
- `KeyboardDialog`-логика — перевод строки в keyCode (`KeyEvent.keyCodeForChar('A') = KEYCODE_A`).
|
||
- `VmGlassesController` (через `runTest`/`TestDispatcher`) — переходы между `ScreenState`.
|
||
- `VmPhoneController` — reconnect backoff логика (1с→2с→4с→8с→16с→30с).
|
||
- `TouchAction`/`TouchType` enum сериализация.
|
||
- `AudioMode`/`OverlayKind` enum сериализация.
|
||
- `LaunchAppCommand` формирование.
|
||
- `GlassesStatus`/`AppCrashedEvent` сериализация.
|
||
|
||
**Что НЕ покрываем unit-тестами (требует устройства):**
|
||
- GL рендер (бинокль).
|
||
- VirtualDisplay creation через Shizuku.
|
||
- WebSocket транспорт (нужен Ktor runtime).
|
||
- mDNS discovery.
|
||
- Mercury BLE.
|
||
- Activity injection events.
|
||
|
||
### 4.10.4. Поток данных
|
||
|
||
```
|
||
[Zona на VirtualDisplay] [Тефон]
|
||
│ │
|
||
│ кадры в Surface │
|
||
▼ │
|
||
[VM-сервис: создал display, запустил Zona] │
|
||
│ │
|
||
│ Surface виртуального дисплея │
|
||
▼ │
|
||
[VmGlassesController] │
|
||
│ │
|
||
│ SurfaceTexture подключен │
|
||
▼ │
|
||
[VirtualDisplayFrameSource] ──► GL-текстура ──► BinocularRenderer │
|
||
(2 копии в 1280×480) │
|
||
│ │
|
||
▼ ▼
|
||
[Очки: оптика делит на 2 глаза]
|
||
│
|
||
Touch/Key events ────────┘
|
||
│
|
||
WifiTransport.bt() / .wifi()
|
||
│
|
||
▼
|
||
[VmProtocol → UserService → injectInputEvent → Zona]
|
||
```
|
||
|
||
### 4.10.5. Звук — два режима
|
||
|
||
> **Важно:** к RayNeo X3 Pro **нельзя подключить наушники** (ни BT, ни USB) — конструктивное ограничение очков. Встроенный динамик очков тихий и низкокачественный. Поэтому передача звука **на телефон** (где у пользователя могут быть нормальные наушники) — **основной use case**, а не опция.
|
||
>
|
||
> Переключатель режима — на телефоне (UI в `TouchpadScreen`), сохраняется в настройках, синхронизируется с очками через `AudioModeCommand`.
|
||
|
||
#### Режим 1: звук на очках (встроенный динамик)
|
||
|
||
- Сценарий: отладка, тихое проигрывание «для проверки», просмотр без наушников.
|
||
- **Подтверждено:** `VirtualDisplay` в Android — **только видео** (документация: `getSurface`/`getDisplay`/etc., ни одного аудио-API). Аудио идёт отдельным каналом через системный `AudioFlinger` в output device. У RayNeo X3 Pro есть встроенный динамик (`STREAM_MUSIC Devices: speaker`).
|
||
- `free.zona` воспроизводит звук штатно через свой `AudioTrack` / `MediaPlayer` в `USAGE_MEDIA/CONTENT_TYPE_MOVIE`. Системный микшер очков играет его во встроенный динамик.
|
||
- **Наш код не вмешивается.** Работает из коробки.
|
||
|
||
#### Режим 2: звук передаётся на телефон (основной сценарий)
|
||
|
||
- Сценарий: пользователь смотрит фильм/контент на очках, слушает через наушники, подключённые к **телефону** (BT-гарнитура, USB-C наушники, встроенный динамик телефона).
|
||
- Передача аудио — ключевая фича. Без неё приложение малополезно для контента со звуком.
|
||
|
||
Цепочка:
|
||
|
||
```
|
||
[free.zona на очках]
|
||
│ AudioTrack → системный микшер
|
||
▼
|
||
[AudioPlaybackCapture наш] ← AudioRecord с config USAGE_MEDIA
|
||
│ PCM-сэмплы
|
||
▼
|
||
[Кодек на очках] ← PCM напрямую или Opus
|
||
│ байты
|
||
▼
|
||
[ByteTransport на очках] ← Transport.wifi(url).send(bytes) или .bt(...)
|
||
│
|
||
│ WiFi / BT
|
||
▼
|
||
[ByteTransport на телефоне] ← принимаем поток
|
||
│ PCM
|
||
▼
|
||
[AudioTrack на телефоне] ← воспроизведение в динамик/наушники
|
||
▼
|
||
[Пользователь слышит звук с телефона]
|
||
```
|
||
|
||
Параллельно — на очках **приглушаем** `STREAM_MUSIC` через `AudioManager`, чтобы не было дубля (звук играет и на очках, и на телефоне).
|
||
|
||
**Ключевые компоненты:**
|
||
- `AudioCaptureService` (в `:app-glasses-vm`) — обёртка над `AudioPlaybackCapture` + `AudioRecord`. Захват PCM с нужной конфигурацией.
|
||
- `AudioStreamer` (в `:app-glasses-vm`) — читает из `AudioCaptureService`, кодит (PCM/Opus), шлёт через `ByteTransport`.
|
||
- `AudioPlayer` (в `:app-phone-vm`) — принимает PCM, играет через `AudioTrack`.
|
||
|
||
**Технические нюансы:**
|
||
|
||
| Нюанс | Решение |
|
||
|---|---|
|
||
| `free.zona` может запретить захват (ALLOW_CAPTURE_BY_SYSTEM вместо _BY_ALL) | При невозможности захвата — переключаемся в режим 1, сообщаем пользователю «приложение не разрешает захват звука». |
|
||
| Задержка передачи | WiFi ~30-100 мс (норм), BT ~100-300 мс (ощутимо). Буферизация + визуальный индикатор задержки. |
|
||
| Формат передачи | **PCM 44.1 kHz 16 bit stereo** через WiFi (176 KB/s, помещается). Через BT — Opus ~30 KB/s (TBD). |
|
||
| **Проигрывание звука на телефоне в фоне** | **Foreground service `AudioPlaybackService`** типа `mediaPlayback` с `PARTIAL_WAKE_LOCK` + `WIFI_MODE_FULL_HIGH_PERF` (см. ниже). |
|
||
| **Захват звука на очках в фоне** | **Foreground service `AudioCaptureService`** типа `mediaProjection` для удержания захвата. |
|
||
|
||
#### 4.10.5.1. Background playback — foreground service
|
||
|
||
**Критичная проблема:** начиная с Android 8.0 (API 26) фоновая работа ограничена. **Китайские прошивки** (HONOR, MIUI, EMUI, ColorOS, OriginOS) **агрессивно убивают фоновые процессы** при выключенном экране. View-mate подтверждает: «HONOR замораживает/убивает процесс в фоне → очки теряют связь (видео паузится) и звук встаёт».
|
||
|
||
**Решение (по образцу `view-mate/app-phone/PlaybackService.kt`):**
|
||
|
||
```kotlin
|
||
// app-phone-vm/.../audio/AudioPlaybackService.kt
|
||
class AudioPlaybackService : Service() {
|
||
private var wakeLock: PowerManager.WakeLock? = null
|
||
private var wifiLock: WifiManager.WifiLock? = null
|
||
|
||
override fun onCreate() {
|
||
super.onCreate()
|
||
// 1. Foreground notification (ОБЯЗАТЕЛЬНО для Android 8+)
|
||
startForeground(NOTIFICATION_ID, buildNotification())
|
||
// 2. Wake lock — CPU не засыпает при выключенном экране
|
||
wakeLock = powerManager.newWakeLock(PARTIAL_WAKE_LOCK, "rayneo-vm:audio")?.apply { acquire() }
|
||
// 3. WiFi lock — WiFi не отключается (иначе WebSocket-канал падает)
|
||
wifiLock = wifiManager.createWifiLock(WIFI_MODE_FULL_HIGH_PERF, "rayneo-vm:wifi")?.apply { acquire() }
|
||
}
|
||
|
||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_STICKY
|
||
override fun onDestroy() { /* release locks */ super.onDestroy() }
|
||
override fun onBind(intent: Intent?): IBinder? = null
|
||
}
|
||
```
|
||
|
||
**Lifecycle:**
|
||
- Стартует когда приходит `SetAudioMode(ON_PHONE)` от пользователя.
|
||
- Останавливается когда приходит `SetAudioMode(ON_GLASSES)`.
|
||
- НЕ всегда-on — только когда нужен режим 2.
|
||
|
||
**Аналогичный `AudioCaptureService` на очках** (для удержания `AudioPlaybackCapture`):
|
||
- Стартует на очках в `VmService.onCreate` когда активен режим 2.
|
||
- Держит `AudioRecord` живым.
|
||
- Foreground service типа `mediaProjection` (Android 14+ требует `FOREGROUND_SERVICE_MEDIA_PROJECTION`).
|
||
|
||
**Permissions для foreground service (Android 14+, API 34):**
|
||
|
||
| Permission | Кто | Зачем |
|
||
|---|---|---|
|
||
| `FOREGROUND_SERVICE` | Оба приложения | Базовое разрешение для foreground service. |
|
||
| `FOREGROUND_SERVICE_MEDIA_PLAYBACK` | PhoneApp | Тип `mediaPlayback` для AudioPlaybackService. |
|
||
| `FOREGROUND_SERVICE_MEDIA_PROJECTION` | GlassesApp | Тип `mediaProjection` для AudioCaptureService. |
|
||
| `WAKE_LOCK` | PhoneApp | Для `PARTIAL_WAKE_LOCK`. |
|
||
| `POST_NOTIFICATIONS` | Оба (Android 13+) | Чтобы foreground notification показывалась. |
|
||
|
||
**Важно:** начиная с Android 14 (`targetSdk >= 34`) **каждый foreground service тип должен быть явно объявлен в манифесте** через `<service android:foregroundServiceType="...">`.
|
||
| Синхронизация A/V | Звук на телефоне может рассинхронизироваться с картинкой в очках. Решается через таймстампы в PCM-пакетах + буфер на принимающей стороне. |
|
||
|
||
---
|
||
|
||
## 5. Нефункциональные требования
|
||
|
||
| Параметр | Значение |
|
||
|---|---|
|
||
| Целевой FPS | ≥ 30, желательно 60 |
|
||
| Суммарная задержка «тап на телефоне → реакция в Zona» | < 100 мс |
|
||
| Суммарная задержка «кадр VirtualDisplay → очки» | < 1 кадра |
|
||
| Энергопотребление | без явных требований, но без лишнего копирования CPU↔GPU |
|
||
| Размер виртуального экрана | строго 640×480 |
|
||
| Минимальный Android API (GlassesApp) | 32 (Android 12) — соответствует RayNeo X3 Pro |
|
||
| Минимальный Android API (PhoneApp) | 26 (Android 8.0) — для совместимости с типичными телефонами |
|
||
| Целевое устройство GlassesApp | RayNeo X3 Pro (ARGF20) |
|
||
| Версия Kotlin | 1.9.x |
|
||
| Compose BOM | 2024.x |
|
||
| AGP | 8.x |
|
||
|
||
### 5.1. Сетевые настройки
|
||
|
||
| Параметр | Значение |
|
||
|---|---|
|
||
| WebSocket-порт на очках | **8080** (WebSocket + путь `/ws/rayneo-vm`) |
|
||
| mDNS service type | **`_rayneo-vm._tcp`** |
|
||
| TLS | Нет (локальная сеть, устройства пользователя) |
|
||
| Аутентификация | Нет (только локальная сеть) |
|
||
|
||
### 5.2. Имена пакетов и namespace
|
||
|
||
| Компонент | Namespace |
|
||
|---|---|
|
||
| GlassesApp | `pw.binom.rayneovm.app.glasses` |
|
||
| PhoneApp | `pw.binom.rayneovm.app.phone` |
|
||
| `:shared` | `pw.binom.rayneovm.shared` |
|
||
| rayneo-mercury новые модули | `pw.binom.mercury.transport`, `pw.binom.mercury.binocularrenderer`, `pw.binom.mercury.shizukuvd` |
|
||
|
||
### 5.3. Имя приложения (для пользователя)
|
||
|
||
- **GlassesApp:** «RayNeo VM» (лаунчер иконка)
|
||
- **PhoneApp:** «RayNeo VM» (лаунчер иконка)
|
||
|
||
### 5.3. Permissions
|
||
|
||
**GlassesApp (`app-glasses-vm/AndroidManifest.xml`):**
|
||
|
||
```xml
|
||
<uses-permission android:name="android.permission.INTERNET" />
|
||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <!-- mDNS multicast -->
|
||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <!-- AudioPlaybackService -->
|
||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Android 13+ для foreground notification -->
|
||
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- PARTIAL_WAKE_LOCK для AudioPlaybackService -->
|
||
<!-- Shizuku permission для VM-сервиса -->
|
||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||
```
|
||
|
||
**PhoneApp (`app-phone-vm/AndroidManifest.xml`):**
|
||
|
||
```xml
|
||
<uses-permission android:name="android.permission.INTERNET" />
|
||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <!-- mDNS -->
|
||
<!-- Mercury BLE -->
|
||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <!-- Android 12+ -->
|
||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- Android 12+ -->
|
||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- для BLE scan на Android 6-11 -->
|
||
<!-- Shizuku permission (необязательно, у нас только Mercury-канал) -->
|
||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||
```
|
||
|
||
### 5.4. Дефолты (фиксировано для ночной сессии)
|
||
|
||
**Версии — latest stable на сентябрь 2026 (по правилу «брать последнее по возможности»):**
|
||
|
||
| Что | Версия | Проверено |
|
||
|---|---|---|
|
||
| Kotlin | **2.4.20** | Maven Central |
|
||
| AGP (Android Gradle Plugin) | **9.4.1** | Maven Google |
|
||
| Gradle | **9.x** (совместимая с AGP 9.4.1) | Maven Google |
|
||
| Compose BOM | **2026.09.00** | Maven Google |
|
||
| AndroidX Lifecycle (вкл. `lifecycle-runtime-compose`) | **2.11.0** | Maven Google |
|
||
| AndroidX Navigation Compose | **2.10.2** | Maven Google |
|
||
| Ktor (server + client) | **3.6.0** | Maven Central |
|
||
| kotlinx-coroutines | **1.11.0** | Maven Central |
|
||
| kotlinx-serialization | **1.11.0** | Maven Central |
|
||
| Shizuku API (`dev.rikka.shizuku:api`) | **13.1.5** | Maven Central |
|
||
| rayneo-mercury | `0.1.0-SNAPSHOT` (текущая в Nexus) | локально |
|
||
|
||
**Заметки:**
|
||
- Compose Compiler **встроен в Kotlin plugin** (с Kotlin 2.0+), не нужна отдельная `kotlinCompilerExtensionVersion`.
|
||
- compileSdk для PhoneApp: **36** (Android 16), minSdk: **26** (Android 8.0).
|
||
- compileSdk для GlassesApp: **36** (для совместимости с актуальными либами), но фактически работаем на API 32.
|
||
- Подпись APK: **debug** (для отладки и ночного тестирования), release потом.
|
||
- Иконка приложения: стандартная Android (потом кастомная).
|
||
|
||
---
|
||
|
||
## 6. Технические ограничения и риски
|
||
|
||
- **Зависимость от Shizuku** — без активного Shizuku GlassesApp не работает. Нужна обработка: показать «запустите Shizuku», предложить инструкцию.
|
||
- **Зависимость от связи** — без PhoneApp очки работают только в пассивном режиме (можно смотреть, но не управлять).
|
||
- **SurfaceTexture ↔ VirtualDisplay** — в некоторых версиях Android нестабильно. Держать в голове план Б через `ImageReader`.
|
||
- **Права shell (UID 2000)** в Shizuku ADB-режиме могут быть недостаточны для части операций. Если упрёмся — план Б: Sui (UID 0).
|
||
- **multicast режется на RayNeo** — fallback на скан /24 (как в view-mate).
|
||
- **RayNeo X3 Pro vs X2** — view-mate писался под X2. Архитектура та же, но возможны мелкие отличия (дисплей 1280×480 vs другой размер). Тестируем на X3 Pro.
|
||
|
||
---
|
||
|
||
## 7. План разработки
|
||
|
||
### Объём первой ночи
|
||
|
||
**Делаем всё, включая коммуникацию между устройствами. Утром — отладка.**
|
||
|
||
Конкретно:
|
||
1. Все модули `rayneo-mercury`: `:transport`, `:binocular-renderer`, `:shizuku-vd` (publish в Nexus).
|
||
2. Все модули `rayneo-vm`: `:shared`, `:app-glasses-vm`, `:app-phone-vm`.
|
||
3. Unit-тесты для всей бизнес-логики.
|
||
4. Полная коммуникация: телефон находит очки по mDNS → WebSocket → UI управления Zona на телефоне → тач/клавиши → на очках → через VM-сервис → `injectInputEvent` → в Zona.
|
||
|
||
### Этапы (последовательность)
|
||
|
||
#### Этап 0. Подготовка `rayneo-mercury` (~2ч)
|
||
|
||
1. `:transport` — перенести код из view-mate/lib-core, namespace `pw.binom.mercury.transport`, API `Transport<T>`/`ServerTransport<T>` + DSL `Transport.wifi(...)`/`.bt(...)`/`.wifiServer(...)`/`.btServer()`. Зависимости: Ktor + kotlinx-serialization. Unit-тесты для DSL и контрактов.
|
||
2. `:binocular-renderer` — обобщить `BinocularVideoPlayer.kt`, интерфейс `FrameSource` + реализации `VideoFrameSource` (для view-mate, **но реализацию делаем минимальной — заглушка, она view-mate нужна только для совместимости**) и `SurfaceTextureFrameSource` (для rayneo-vm, основная).
|
||
3. `:shizuku-vd` — AIDL-биндер + UserService обёртка (`createVirtualDisplay`, `startActivityAsUser`, `injectInputEvent`).
|
||
4. Опубликовать все три в локальный Nexus.
|
||
|
||
#### Этап 1. Bootstrap `rayneo-vm` (~1ч)
|
||
|
||
5. Gradle-проект rayneo-vm с тремя модулями (`:shared`, `:app-glasses-vm`, `:app-phone-vm`).
|
||
6. `gradle.properties`, `settings.gradle.kts`, корневой `build.gradle.kts` (плагины: AGP, Kotlin, Compose, kotlinx-serialization).
|
||
7. Подключить зависимости из Nexus + Shizuku API + RayNeo SDK.
|
||
8. Минимальные `MainActivity` в обоих приложениях + build OK.
|
||
|
||
#### Этап 2. Модуль `:shared` (~30мин)
|
||
|
||
9. DTO: `VmMessage` (sealed), `TouchEvent`, `TouchAction`, `KeyEvent`, `KeyAction`, `GlassesStatus`, `LaunchAppCommand`, `AudioModeCommand`.
|
||
10. Сериализация (kotlinx-serialization, JSON).
|
||
11. Unit-тесты: roundtrip сериализации, парсинг всех вариантов `VmMessage`.
|
||
|
||
#### Этап 3. `:app-glasses-vm` — рендер (~1ч)
|
||
|
||
12. `VmGlassesApp : Application` (инициализация Shizuku listener).
|
||
13. `MainActivity` (fullscreen + immersive).
|
||
14. `VmGlassesController` — оркестратор.
|
||
15. `VirtualDisplayFrameSource : FrameSource` — пока с `SurfaceTexture` от чёрного bitmap (заглушка).
|
||
16. `BinocularRenderer` — рендерит в обе половины.
|
||
17. Unit-тесты для `FrameSource` контракта.
|
||
|
||
#### Этап 4. `:app-glasses-vm` — VM-сервис и VirtualDisplay (~2ч)
|
||
|
||
18. AIDL: `IVmShizukuService` с методами `createVirtualDisplay()` / `launchApp(packageName)` / `injectInputEvent(event)`.
|
||
19. `VmShizukuUserService : UserService` — реализация в отдельном процессе с правами shell:
|
||
- `IDisplayManager.createVirtualDisplay(...)` без `OWN_CONTENT_ONLY`.
|
||
- `IActivityTaskManager.startActivityAsUser(...)` для `free.zona`.
|
||
- `InputManager.injectInputEvent(...)` для инпута.
|
||
20. `VmShizukuService` (клиент) — биндинг через `Shizuku.bindUserService`.
|
||
21. Surface виртуального дисплея → через биндер в `MainActivity` → `VirtualDisplayFrameSource`.
|
||
|
||
#### Этап 5. `:app-phone-vm` — UI (~2ч)
|
||
|
||
22. Material 3 тёмная тема.
|
||
23. `HomeScreen` + `HomeViewModel` + bottom navigation.
|
||
24. `GlassesStatusScreen` + ViewModel.
|
||
25. `AppsListScreen` + ViewModel + `AppProfile`.
|
||
26. `AppControlScreen` + ViewModel.
|
||
27. `ZonaProfile` (тачпад + медиа-кнопки + клавиатура + назад).
|
||
28. `TouchpadView`, `MediaButton`, `KeyboardDialog`, `ConnectionStatus`.
|
||
29. `VmPhoneController` (транспорт, протокол, lifecycle).
|
||
30. Unit-тесты для ViewModel'ей и `VmProtocol`.
|
||
|
||
#### Этап 6. Коммуникация (~2ч)
|
||
|
||
31. В GlassesApp: `WifiServerTransport` (поднимаем WebSocket-сервер на очках).
|
||
32. mDNS discovery на телефоне (через `:transport` `MdnsScanner`).
|
||
33. В PhoneApp: `WifiTransport`-клиент, подключение по найденному URL.
|
||
34. `VmProtocol` — обёртка, кодит/декодит VmMessage.
|
||
35. Передача `TouchEvent`/`KeyEvent` Phone → Glasses → VM-сервис → `injectInputEvent` в Zona.
|
||
36. Передача `LaunchAppCommand`.
|
||
37. Авто-реконнект с экспоненциальным backoff + Snackbar.
|
||
38. Unit-тесты: маппинг координат, формирование KeyEvent из символов, парсинг входящих сообщений.
|
||
|
||
#### Этап 7. Звук — режим 2 (~2ч, если успеем)
|
||
|
||
39. `AudioCaptureService` (AudioPlaybackCapture, USAGE_MEDIA).
|
||
40. `AudioStreamer` (PCM → ByteTransport).
|
||
41. `AudioPlayer` (ByteTransport → AudioTrack на телефоне).
|
||
42. `AudioModeCommand` в протоколе.
|
||
43. Приглушение звука на очках в режиме 2.
|
||
|
||
#### Этап 8. Shizuku UX (~30мин)
|
||
|
||
44. `ShizukuStatusChecker` — проверка при старте.
|
||
45. `ShizukuActivationScreen` — экран с инструкцией и кнопкой.
|
||
46. Listener на `Shizuku.addBinderReceivedListener` для авто-скрытия блока.
|
||
|
||
### Что НЕ делаем на этой ночи
|
||
|
||
- Аналитику, крэш-репорты.
|
||
- Логирование в файл (только logcat).
|
||
- Поддержку нескольких одновременных подключений к GlassesApp.
|
||
- Сложные жесты (мультитач, pinch-to-zoom).
|
||
- A/B-тесты, дизайн-систему вне Material 3.
|
||
- CI/CD.
|
||
- Авто-установку приложений, которых нет на очках.
|
||
|
||
### Что проверяем утром
|
||
|
||
- Видна ли Zona в очках.
|
||
- Работает ли тачпад (тап/свайг).
|
||
- Работают ли медиа-кнопки (⏪⏯⏩).
|
||
- Работает ли клавиатура (ввести название фильма, найти).
|
||
- Работает ли кнопка назад.
|
||
- Работает ли авто-реконнект (выключить WiFi на очках, включить — должно восстановиться).
|
||
- Работает ли режим 2 звука (если сделали).
|
||
|
||
---
|
||
|
||
## 8. Целевое устройство, гостевое приложение и привилегии
|
||
|
||
### 8.1. Устройство (подтверждено через `adb`)
|
||
|
||
| Параметр | Значение |
|
||
|---|---|
|
||
| Manufacturer | RayNeo |
|
||
| Модель | ARGF20 |
|
||
| Android | 12 |
|
||
| API level | 32 |
|
||
| ABI | arm64-v8a |
|
||
| Физический дисплей | **1 шт**, 1280×480, 60 FPS, density 160 dpi |
|
||
| `DisplayInfo.flag` | `FLAG_SECURE`, `FLAG_SUPPORTS_PROTECTED_BUFFERS` |
|
||
|
||
### 8.2. Дисплей очков
|
||
|
||
RayNeo X3 Pro система видит как **один** дисплей `1280×480`. Оптика очков сама делит картинку на две половины по 640×480 и подаёт каждую на свой глаз.
|
||
|
||
- Никаких «двух display id» (L / R) нет — есть один `displayId=0`.
|
||
- GlassesApp просто рисует картинку 1280×480, где **левая половина → левый глаз, правая → правый**.
|
||
- VirtualDisplay 640×480 по размеру = одной «глазной» половине. Удобно.
|
||
|
||
### 8.3. Гостевое приложение
|
||
|
||
**Zona** (`free.zona`) — онлайн-кинотеатр.
|
||
|
||
| Параметр | Значение |
|
||
|---|---|
|
||
| Package | `free.zona` |
|
||
| `MainActivity` | `free.zona/ru.zona.app.android.MainActivity` |
|
||
| `versionName` / `versionCode` | `3.0.61` / `1061` |
|
||
| `targetSdk` / `minSdk` | `29` / `21` |
|
||
| Размер APK | ~19 MB |
|
||
| Intent-filter | `android.intent.action.MAIN` ✓ |
|
||
|
||
### 8.4. Shizuku
|
||
|
||
Установлен и активирован **Shizuku Manager** (пакет `moe.shizuku.privileged.api`, версия `13.6.0`, ADB-режим, `shizuku_server` от UID 2000).
|
||
|
||
Через Shizuku делаем всю работу с системными API:
|
||
1. Создание VirtualDisplay **без** `VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY`.
|
||
2. Запуск **чужой** Activity на этом дисплее через `IActivityTaskManager.startActivityAsUser`.
|
||
3. Передача инпута в гостевое приложение через `InputManager.injectInputEvent`.
|
||
|
||
#### 8.4.1. Используемые API Shizuku
|
||
|
||
- **UserService** (`Shizuku.bindUserService`) — наш VM-сервис в отдельном процессе с правами shell.
|
||
- **Permission** — `Shizuku.requestPermission(REQUEST_CODE)`.
|
||
- **Provider** — `rikka.shizuku.ShizukuProvider` в `AndroidManifest.xml`.
|
||
|
||
API Shizuku: `dev.rikka.shizuku:api:13.1.5+`.
|
||
|
||
#### 8.4.2. Разрешения в `AndroidManifest.xml`
|
||
|
||
```xml
|
||
<uses-permission android:name="android.permission.INTERNET" />
|
||
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||
|
||
<application ...>
|
||
<provider
|
||
android:name="rikka.shizuku.ShizukuProvider"
|
||
android:authorities="${applicationId}.shizuku"
|
||
android:multiprocess="false"
|
||
android:enabled="true"
|
||
android:exported="true"
|
||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||
</application>
|
||
```
|
||
|
||
#### 8.4.3. Альтернативы Shizuku (резюме)
|
||
|
||
Подробный разбор в v0.5 ТЗ (см. историю изменений). Короткий вывод:
|
||
|
||
| Способ | Подходит? |
|
||
|---|---|
|
||
| **Shizuku (UID 2000) через UserService** | ✓ основной |
|
||
| **Root / Sui (UID 0)** | ✓ план Б, требует Magisk |
|
||
| `DisplayManager.createVirtualDisplay()` без Shizuku | ✗ (только OWN_CONTENT_ONLY) |
|
||
| `MediaProjection` | ✗ (только захват) |
|
||
| `Presentation` | ✗ (только своё UI) |
|
||
| Подпись ключом RayNeo | ✗ (недостижимо) |
|
||
|
||
---
|
||
|
||
## 9. Общая либа `rayneo-mercury` — новые модули
|
||
|
||
### 9.1. Репозиторий
|
||
|
||
`/home/subochev/common-projects/WORK/rayneo-mercury/` — Android Library, публикуется в локальный Nexus `http://192.168.76.117/repository/caffeine/` под groupId `pw.binom.mercury`.
|
||
|
||
### 9.2. Новые модули (добавляем)
|
||
|
||
#### `:transport`
|
||
|
||
- Назначение: **универсальный** канал связи клиент↔сервер по сети. Не привязан к домену RayNeo, очкам или приложению.
|
||
- Источник кода: `view-mate/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/` (`GlassesTransport.kt`, `WifiGlassesTransport.kt`, `GlassesWsClient.kt`, `BtTransportConstants.kt`), `view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/` (`MdnsClient.kt`, `PhoneScan.kt`).
|
||
- **Принцип разделения:** либа решает только **КАК** передавать байты/строки (транспорт, реконнект, discovery). **ЧТО** передавать — решает вызывающий (DTO и формат сериализации — в коде приложения, не в либе).
|
||
- Namespace: `pw.binom.mercury.transport`. Зависимости — Ktor + kotlinx-serialization. Без KMP (Android Library, как и `library` в rayneo-mercury).
|
||
|
||
**Публичный API:**
|
||
|
||
```kotlin
|
||
// Клиентский транспорт: подключается к серверу, отправляет/получает сообщения
|
||
interface Transport<T> {
|
||
val name: String
|
||
suspend fun connect(
|
||
onMessage: suspend (T) -> Unit,
|
||
onConnected: suspend () -> Unit,
|
||
onDisconnected: suspend () -> Unit,
|
||
)
|
||
suspend fun send(message: T)
|
||
fun close()
|
||
}
|
||
|
||
// Серверный транспорт: принимает подключения, шлёт по connId
|
||
interface ServerTransport<T> {
|
||
val name: String
|
||
suspend fun accept(
|
||
onMessage: suspend (connId: String, message: T) -> Unit,
|
||
onConnected: (connId: String) -> Unit,
|
||
onDisconnected: (connId: String) -> Unit,
|
||
)
|
||
suspend fun send(connId: String, message: T)
|
||
fun close()
|
||
}
|
||
|
||
// Удобные typealias'ы для типичных кейсов
|
||
typealias StringTransport = Transport<String> // JSON-строки, текст
|
||
typealias ByteTransport = Transport<ByteArray> // бинарный (protobuf, msgpack, raw)
|
||
```
|
||
|
||
**DSL-фабрики** (extension functions на `Transport.Companion` — короткий идиоматичный вызов):
|
||
|
||
```kotlin
|
||
Transport.wifi("ws://192.168.1.42:8080/ws") // StringTransport, клиент
|
||
Transport.wifiServer(port = 8080) // ServerTransport<String>, сервер
|
||
Transport.bt("00:11:22:33:44:55") // StringTransport, клиент
|
||
Transport.btServer() // ServerTransport<String>, сервер
|
||
```
|
||
|
||
Это просто синтаксический сахар над конструкторами конкретных классов — никакой магии.
|
||
|
||
**Реализации:**
|
||
|
||
| Класс | Роль | Транспорт |
|
||
|---|---|---|
|
||
| `WifiTransport(url: String)` | клиент | WebSocket через Ktor |
|
||
| `WifiServerTransport(port: Int)` | сервер | WebSocket через Ktor |
|
||
| `BtTransport(macAddress: String)` | клиент | Bluetooth SPP / BLE |
|
||
| `BtServerTransport` | сервер | Bluetooth SPP / BLE |
|
||
|
||
**Discovery (отдельные классы, не часть `Transport`):**
|
||
|
||
| Класс | Что делает |
|
||
|---|---|
|
||
| `MdnsScanner(serviceType: String, fallbackScan: Boolean = true)` | mDNS discovery + fallback на скан /24 (на некоторых устройствах multicast режется) |
|
||
| `BtScanner` | Bluetooth discovery |
|
||
|
||
**Чего в либе НЕТ** (намеренно):
|
||
- Никаких DTO с `@SerialName("touch")` / `GlassesToHost` / `HostToGlasses`.
|
||
- Никакого знания про режимы (`MOVIE`, `CHAT`) или жесты.
|
||
- Никаких упоминаний view-mate, RayNeo или конкретного приложения в именах/комментариях.
|
||
|
||
**Использование в rayneo-vm (в коде приложения, не в либе):**
|
||
|
||
```kotlin
|
||
// Свои DTO и протокол — на стороне приложения
|
||
@Serializable sealed interface VmMessage
|
||
@Serializable @SerialName("touch") data class TouchEvent(...) : VmMessage
|
||
@Serializable @SerialName("key") data class KeyEvent(...) : VmMessage
|
||
@Serializable @SerialName("status") data class GlassesStatus(...) : VmMessage
|
||
|
||
// Свой адаптер поверх транспорта
|
||
class VmProtocol(transport: StringTransport) {
|
||
val incoming: Flow<VmMessage> = callbackFlow {
|
||
transport.connect(
|
||
onMessage = { json -> trySend(Json.decodeFromString<VmMessage>(json)) },
|
||
onConnected = { /* update UI */ },
|
||
onDisconnected = { /* update UI */ },
|
||
)
|
||
awaitClose { transport.close() }
|
||
}
|
||
suspend fun send(msg: VmMessage) = transport.send(Json.encodeToString(msg))
|
||
}
|
||
```
|
||
|
||
- Maven-координаты: `pw.binom.mercury:transport:<version>`.
|
||
|
||
#### `:binocular-renderer`
|
||
|
||
- Назначение: EGL-бинокулярный рендерер на очках.
|
||
- Источник кода: `view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt`.
|
||
- Адаптация: выделить интерфейс `FrameSource`. Реализации:
|
||
- `VideoFrameSource` (для view-mate, ExoPlayer → Surface → текстура).
|
||
- `SurfaceTextureFrameSource` (для rayneo-vm, VirtualDisplay surface → SurfaceTexture → текстура).
|
||
- Maven-координаты: `pw.binom.mercury:binocular-renderer:<version>`.
|
||
|
||
#### `:shizuku-vd` (опционально)
|
||
|
||
- Назначение: удобная обёртка над Shizuku UserService для создания VirtualDisplay и запуска Activity.
|
||
- Реализация: биндинг между основным приложением и UserService через AIDL.
|
||
- В VM-сервисе: вся логика создания display + запуска Activity + проброс Surface.
|
||
- Maven-координаты: `pw.binom.mercury:shizuku-vd:<version>`.
|
||
|
||
### 9.3. Версионирование и публикация
|
||
|
||
- Версия модулей берётся из `gradle.properties` (`mercuryVersion`, по аналогии с существующим `library`).
|
||
- Публикация: `./gradlew :transport:publish :binocular-renderer:publish` → Nexus `caffeine`.
|
||
- Подключение в rayneo-vm:
|
||
```kotlin
|
||
implementation("pw.binom.mercury:transport:$mercuryVersion")
|
||
implementation("pw.binom.mercury:binocular-renderer:$mercuryVersion")
|
||
implementation("pw.binom.mercury:shizuku-vd:$mercuryVersion")
|
||
```
|
||
|
||
### 9.4. Что делаем с view-mate
|
||
|
||
**Ничего.** view-mate продолжает жить со своим lib-core. Миграция на rayneo-mercury — задача другого агента (по просьбе пользователя).
|
||
|
||
---
|
||
|
||
## 10. Открытые вопросы
|
||
|
||
### Закрытые (решены)
|
||
|
||
- [x] Гостевое приложение — **Zona (`free.zona`)**, см. 8.3.
|
||
- [x] Целевое устройство — RayNeo X3 Pro (ARGF20, Android 12, API 32), см. 8.1.
|
||
- [x] Display id очков — **один** (`displayId=0`, 1280×480), оптика делит на два глаза по 640×480, см. 8.2.
|
||
- [x] Привилегии — через **Shizuku**, см. 8.4.
|
||
- [x] Альтернативы Shizuku — единственный практический путь, см. 8.4.3.
|
||
- [x] Связь телефон ↔ очки — WebSocket по WiFi + BT-fallback через модуль `:transport`, см. 4.7.
|
||
- [x] Общая либа — rayneo-mercury (новые модули `:transport`, `:binocular-renderer`, `:shizuku-vd`), см. 9.
|
||
- [x] Протокол — расширяем существующий из view-mate (TouchEvent, KeyEvent, GlassesStatus), см. 4.7.2.
|
||
- [x] Звук: 2 режима (на очках / на телефоне), переключатель в PhoneApp, см. 4.10.5.
|
||
- [x] UI PhoneApp: Material 3, тёмная тема, bottom navigation, тачпад с серым фоном и точкой касания.
|
||
- [x] Shizuku UX: блок + кнопка активации + инструкция.
|
||
- [x] Потеря связи: авто-реконнект с экспоненциальным backoff + Snackbar.
|
||
- [x] Архитектура UI: MVVM + ViewModel + StateFlow.
|
||
- [x] Тесты: unit на JVM для всего что можно.
|
||
|
||
### Открытые (проверим экспериментально утром)
|
||
|
||
- [ ] Подтвердить экспериментально, что через Shizuku UserService создаётся VirtualDisplay **без** OWN_CONTENT_ONLY и на нём реально стартует `free.zona`.
|
||
- [ ] Режим 2 звука: подтвердить, что `AudioPlaybackCapture` работает на `free.zona` (не запрещает ли она захват). Если запрещает — fallback на режим 1 + сообщение.
|
||
- [ ] Режим 2 звука: PCM напрямую или сжимать (Opus)? PCM проще, но ~176 KB/s; Opus ~30 KB/s, но добавляет кодек. Уточнить после замера задержки.
|
||
- [ ] Режим 2 звука: использовать ли BT-канал для передачи звука (медленнее) или только WiFi (быстрее)?
|
||
- [ ] Какие требования по задержке и FPS — есть ли порог «некомфортно»?
|
||
|
||
### Отложенные (будущее, не сейчас)
|
||
|
||
- [ ] Нужно ли давать пользователю UI-управление на самих очках (жесты/кнопки), или только с телефона?
|
||
- [ ] Нужен ли multi-touch (pinch-to-zoom) или достаточно single-touch?
|
||
- [ ] Поддержка других приложений (Jellyfin и т.п.) — добавлять новые `AppProfile`.
|
||
- [ ] Подтвердить, что RayNeo X3 Pro ведёт себя так же, как X2 (на котором написан view-mate), по части дисплея, жестов, multicast и т.д.
|
||
|
||
---
|
||
|
||
## 11. История изменений
|
||
|
||
| Дата | Версия | Что изменилось |
|
||
|---|---|---|
|
||
| 2026-09-25 | 0.1 | Первичный черновик |
|
||
| 2026-09-25 | 0.2 | Уточнены: целевое устройство, дисплей, гостевое приложение = Zona. |
|
||
| 2026-09-25 | 0.3 | Добавлен раздел про Shizuku. |
|
||
| 2026-09-25 | 0.4 | Архитектура обновлена под Shizuku UserService. |
|
||
| 2026-09-25 | 0.5 | Раздел про альтернативы Shizuku, отложенный инпут. |
|
||
| 2026-09-25 | 0.6 | Архитектура расширена: два подмодуля (GlassesApp + PhoneApp) + общая либа `rayneo-mercury` с новыми модулями `:transport`, `:binocular-renderer`, `:shizuku-vd`. Связь через WebSocket, протокол расширен TouchEvent/KeyEvent/GlassesStatus. Источник переиспользования — `/home/subochev/common-projects/WORK/ai/view-mate/`. view-mate не трогаем. |
|
||
| 2026-09-25 | 0.7 | Раздел 9.2 (`:transport`) переработан: либа теперь универсальная, не привязана к RayNeo/view-mate. API — `Transport<T>` / `ServerTransport<T>` (generic по типу сообщения) + typealias'ы `StringTransport` / `ByteTransport`. Протокол (DTO, сериализация) вынесен из либы в код rayneo-vm. Либа не знает про домен, режимы, жесты — только «как передавать байты». |
|
||
| 2026-09-25 | 0.8 | DSL-фабрики `Transport.wifi(...)` / `Transport.bt(...)` / `Transport.wifiServer(...)` / `Transport.btServer()` (extension functions на `Transport.Companion`) — короткий идиоматичный Kotlin-вызов. |
|
||
| 2026-09-25 | 0.9 | Добавлен раздел 4.10 «Структура приложения rayneo-vm»: 3 Gradle-модуля (`:app-glasses-vm`, `:app-phone-vm`, `:shared`), перечислены классы и поток данных. UI на PhoneApp — Compose. DTO живут в `:shared` (не в rayneo-mercury). |
|
||
| 2026-09-25 | 0.10 | Добавлен раздел 4.10.5 «Звук — два режима»: режим 1 (звук на очках, из коробки), режим 2 (через `AudioPlaybackCapture` + `ByteTransport` → `AudioTrack` на телефоне). Описаны компоненты `AudioCaptureService`, `AudioStreamer`, `AudioPlayer`, приглушение звука на очках в режиме 2, риски задержки и ALLOW_CAPTURE_*. |
|
||
| 2026-09-25 | 0.11 | Раздел 4.10.5 уточнён: режим 1 подтверждён эмпирически (`dumpsys audio` на устройстве — `STREAM_MUSIC Devices: speaker, remote_submix`, BT A2DP sink и USB-аудио доступны). VirtualDisplay в Android — только видео, аудио идёт через AudioFlinger в обычный output device. |
|
||
| 2026-09-25 | 0.12 | Звук: зафиксировано, что к RayNeo X3 Pro **нельзя подключить наушники** — конструктивное ограничение. Режим 2 (передача звука на телефон) — **основной use case**, режим 1 — для отладки/тихого просмотра. |
|
||
| 2026-09-25 | 0.13 | Раздел 4.6 расширен: UI PhoneApp = HomeScreen с двумя табами («Очки» статус, «Приложения» список). Введён интерфейс `AppProfile` для специфичного UI каждого приложения. ZonaProfile: тачпад + ⏪⏯⏩⌨← + диалог клавиатуры с полем ввода и кнопкой «Отправить». Новые клавиши — медиа (`KEYCODE_MEDIA_*`) и `KEYCODE_BACK` — через тот же `KeyEvent` DTO. Тап на приложение в списке → `LaunchAppCommand` → VM-сервис запускает/фокусирует Activity → переход на `AppControlScreen(profile)`. |
|
||
| 2026-09-25 | 0.14 | Раздел 4.6.7-4.6.10: зафиксированы design-решения (Material 3, тёмная тема, bottom nav, тачпад с серым фоном + точкой касания), Shizuku UX (блок + кнопка активации), поведение при потере связи (авто-реконнект с backoff + Snackbar), архитектура UI (MVVM + ViewModel + StateFlow). Раздел 7 (план разработки) переписан: «делаем всё за ночь, утром отладка» с 8 этапами и конкретными unit-тестами. Открытые вопросы структурированы: закрытые / открытые экспериментальные / отложенные. |
|
||
| 2026-09-25 | 0.15 | Добавлен раздел 4.6.6: интеграция с фирменным BLE-протоколом **RayNeo Mercury** (`pw.binom.mercury:library`) — служебный канал для спаривания, статуса очков, и **запуска нашего GlassesApp по пакету через `appMarketLaunch`**. `GlassesStatusScreen` дополнен: показывает статус BLE-соединения, батарею, и **кнопку «▶ Запустить наше приложение на очках»** если WebSocket-канал не поднялся (определяем через mDNS discovery). Запуск гостевого приложения (Zona) — через WebSocket `LaunchAppCommand`. Зависимости `app-phone-vm` расширены `:library`. |
|
||
| 2026-09-25 | 0.16 | Добавлен раздел 4.6.11 «Обработка краша гостевого приложения»: детектирование через `IActivityTaskManager.registerTaskStackListener(...)` в VM-сервисе, событие `AppCrashedEvent` через WebSocket, на PhoneApp — Snackbar + `popBackStack()` на `AppsListScreen`. **Без автоперезапуска** (по решению пользователя — пользователь сам решает). Таблица что происходит когда что падает (Activity/процесс/VM-сервис/GlassesApp). Edge cases для VM-сервиса и GlassesApp отмечены как отложенные. |
|
||
| 2026-09-25 | 0.17 | Добавлен раздел 4.6.12 «Состояния экрана очков» — 5 состояний через `OverlayFrameSource`: «Всё работает», «Нет связи» (overlay поверх кадра), «Приложение не выбрано» (чёрный + надпись), «Shizuku не активен» (чёрный + инструкция), «Стартует». Добавлен раздел 5 «Нефункциональные требования» с подсекциями: сетевые настройки (WebSocket 8080, mDNS `_rayneo-vm._tcp`), имена пакетов/namespace, имя приложения «RayNeo VM», технические дефолты (Kotlin 1.9.x, Compose BOM 2024.x, AGP 8.x, rayneo-mercury 0.1.0-SNAPSHOT, Shizuku 13.1.5). |
|
||
| 2026-09-25 | 0.18 | Разделы 4.1 и 4.2 переписаны: GlassesApp UI тоже на **Jetpack Compose** (как PhoneApp). GL-рендер встроен через `AndroidView { SurfaceView }` (паттерн из view-mate `BinocularVideoPlayer.kt`). Публичный API `:binocular-renderer` — `@Composable fun BinocularRendererView(frameSource, modifier)`. Compose-дерево GlassesApp: `Box` с двумя слоями — GL-рендер (BinocularRendererView) для `Running` состояния + Compose overlay для всех остальных состояний. |
|
||
| 2026-09-25 | 0.21 | Раздел 4.10.5: добавлен 4.10.5.1 «Background playback — foreground service» с детальным описанием `AudioPlaybackService` (PhoneApp, тип `mediaPlayback`, `PARTIAL_WAKE_LOCK` + `WIFI_MODE_FULL_HIGH_PERF`) и `AudioCaptureService` (GlassesApp, тип `mediaProjection`). Паттерн взят из view-mate/app-phone/PlaybackService.kt — без него китайские ROM (HONOR/MIUI/EMUI/ColorOS) убивают процесс при выключенном экране. Обновлён 5.3 «Permissions»: добавлены `FOREGROUND_SERVICE_MEDIA_PLAYBACK` (PhoneApp), `FOREGROUND_SERVICE_MEDIA_PROJECTION` (GlassesApp), `WAKE_LOCK` (PhoneApp). |
|