# ТЗ: 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 = 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` - `glassesBattery: StateFlow` - `glassesBrightness: StateFlow` - `glassesWifi: StateFlow` - `appLaunchedOnGlasses: StateFlow` — обновляется через mDNS-сканирование каждые 5с - `wsConnected: StateFlow` — состояние 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`, 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`, рендерер подписывается и переключает `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 // серия 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::`, репо — локальный 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) : 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 тип должен быть явно объявлен в манифесте** через ``. | Синхронизация 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 ``` **PhoneApp (`app-phone-vm/AndroidManifest.xml`):** ```xml ``` ### 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`/`ServerTransport` + 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 ``` #### 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 { 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 { 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 // JSON-строки, текст typealias ByteTransport = Transport // бинарный (protobuf, msgpack, raw) ``` **DSL-фабрики** (extension functions на `Transport.Companion` — короткий идиоматичный вызов): ```kotlin Transport.wifi("ws://192.168.1.42:8080/ws") // StringTransport, клиент Transport.wifiServer(port = 8080) // ServerTransport, сервер Transport.bt("00:11:22:33:44:55") // StringTransport, клиент Transport.btServer() // ServerTransport, сервер ``` Это просто синтаксический сахар над конструкторами конкретных классов — никакой магии. **Реализации:** | Класс | Роль | Транспорт | |---|---|---| | `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 = callbackFlow { transport.connect( onMessage = { json -> trySend(Json.decodeFromString(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:`. #### `: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:`. #### `:shizuku-vd` (опционально) - Назначение: удобная обёртка над Shizuku UserService для создания VirtualDisplay и запуска Activity. - Реализация: биндинг между основным приложением и UserService через AIDL. - В VM-сервисе: вся логика создания display + запуска Activity + проброс Surface. - Maven-координаты: `pw.binom.mercury:shizuku-vd:`. ### 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` / `ServerTransport` (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). |