Files
rayneo-vm/docs/SPEC.md
T
2026-09-25 13:24:56 +03:00

1370 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ: 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). |