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

94 KiB
Raw Blame History

ТЗ: 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-дерева:

@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:

@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:

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

@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 и кнопкой «Отправить»:

@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:

@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:

// от 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 модуля

// Базовый 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):

// 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):

<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):

<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ч)

  1. Gradle-проект rayneo-vm с тремя модулями (:shared, :app-glasses-vm, :app-phone-vm).
  2. gradle.properties, settings.gradle.kts, корневой build.gradle.kts (плагины: AGP, Kotlin, Compose, kotlinx-serialization).
  3. Подключить зависимости из Nexus + Shizuku API + RayNeo SDK.
  4. Минимальные MainActivity в обоих приложениях + build OK.

Этап 2. Модуль :shared (~30мин)

  1. DTO: VmMessage (sealed), TouchEvent, TouchAction, KeyEvent, KeyAction, GlassesStatus, LaunchAppCommand, AudioModeCommand.
  2. Сериализация (kotlinx-serialization, JSON).
  3. Unit-тесты: roundtrip сериализации, парсинг всех вариантов VmMessage.

Этап 3. :app-glasses-vm — рендер (~1ч)

  1. VmGlassesApp : Application (инициализация Shizuku listener).
  2. MainActivity (fullscreen + immersive).
  3. VmGlassesController — оркестратор.
  4. VirtualDisplayFrameSource : FrameSource — пока с SurfaceTexture от чёрного bitmap (заглушка).
  5. BinocularRenderer — рендерит в обе половины.
  6. Unit-тесты для FrameSource контракта.

Этап 4. :app-glasses-vm — VM-сервис и VirtualDisplay (~2ч)

  1. AIDL: IVmShizukuService с методами createVirtualDisplay() / launchApp(packageName) / injectInputEvent(event).
  2. VmShizukuUserService : UserService — реализация в отдельном процессе с правами shell:
    • IDisplayManager.createVirtualDisplay(...) без OWN_CONTENT_ONLY.
    • IActivityTaskManager.startActivityAsUser(...) для free.zona.
    • InputManager.injectInputEvent(...) для инпута.
  3. VmShizukuService (клиент) — биндинг через Shizuku.bindUserService.
  4. Surface виртуального дисплея → через биндер в MainActivity → VirtualDisplayFrameSource.

Этап 5. :app-phone-vm — UI (~2ч)

  1. Material 3 тёмная тема.
  2. HomeScreen + HomeViewModel + bottom navigation.
  3. GlassesStatusScreen + ViewModel.
  4. AppsListScreen + ViewModel + AppProfile.
  5. AppControlScreen + ViewModel.
  6. ZonaProfile (тачпад + медиа-кнопки + клавиатура + назад).
  7. TouchpadView, MediaButton, KeyboardDialog, ConnectionStatus.
  8. VmPhoneController (транспорт, протокол, lifecycle).
  9. Unit-тесты для ViewModel'ей и VmProtocol.

Этап 6. Коммуникация (~2ч)

  1. В GlassesApp: WifiServerTransport (поднимаем WebSocket-сервер на очках).
  2. mDNS discovery на телефоне (через :transport MdnsScanner).
  3. В PhoneApp: WifiTransport-клиент, подключение по найденному URL.
  4. VmProtocol — обёртка, кодит/декодит VmMessage.
  5. Передача TouchEvent/KeyEvent Phone → Glasses → VM-сервис → injectInputEvent в Zona.
  6. Передача LaunchAppCommand.
  7. Авто-реконнект с экспоненциальным backoff + Snackbar.
  8. Unit-тесты: маппинг координат, формирование KeyEvent из символов, парсинг входящих сообщений.

Этап 7. Звук — режим 2 (~2ч, если успеем)

  1. AudioCaptureService (AudioPlaybackCapture, USAGE_MEDIA).
  2. AudioStreamer (PCM → ByteTransport).
  3. AudioPlayer (ByteTransport → AudioTrack на телефоне).
  4. AudioModeCommand в протоколе.
  5. Приглушение звука на очках в режиме 2.

Этап 8. Shizuku UX (~30мин)

  1. ShizukuStatusChecker — проверка при старте.
  2. ShizukuActivationScreen — экран с инструкцией и кнопкой.
  3. 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

<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:

// Клиентский транспорт: подключается к серверу, отправляет/получает сообщения
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 — короткий идиоматичный вызов):

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 (в коде приложения, не в либе):

// Свои 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:
    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. Открытые вопросы

Закрытые (решены)

  • Гостевое приложение — Zona (free.zona), см. 8.3.
  • Целевое устройство — RayNeo X3 Pro (ARGF20, Android 12, API 32), см. 8.1.
  • Display id очков — один (displayId=0, 1280×480), оптика делит на два глаза по 640×480, см. 8.2.
  • Привилегии — через Shizuku, см. 8.4.
  • Альтернативы Shizuku — единственный практический путь, см. 8.4.3.
  • Связь телефон ↔ очки — WebSocket по WiFi + BT-fallback через модуль :transport, см. 4.7.
  • Общая либа — rayneo-mercury (новые модули :transport, :binocular-renderer, :shizuku-vd), см. 9.
  • Протокол — расширяем существующий из view-mate (TouchEvent, KeyEvent, GlassesStatus), см. 4.7.2.
  • Звук: 2 режима (на очках / на телефоне), переключатель в PhoneApp, см. 4.10.5.
  • UI PhoneApp: Material 3, тёмная тема, bottom navigation, тачпад с серым фоном и точкой касания.
  • Shizuku UX: блок + кнопка активации + инструкция.
  • Потеря связи: авто-реконнект с экспоненциальным backoff + Snackbar.
  • Архитектура UI: MVVM + ViewModel + StateFlow.
  • Тесты: 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).