94 KiB
ТЗ: 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. Поток данных
- Пользователь на телефоне тапает по тачпаду → PhoneApp формирует событие.
- PhoneApp отправляет событие по WebSocket-каналу в GlassesApp.
- GlassesApp пересылает событие в VM-сервис через
InputManager.injectInputEvent(...)— событие попадает в активное приложение (free.zona) на VirtualDisplay. free.zonaрисует кадр в Surface виртуального дисплея.- Кадр через
SurfaceTexture→ GL-текстуру → binocular-renderer → 2 копии в1280×480физический дисплей очков. - Оптика очков раздаёт левую половину на левый глаз, правую — на правый.
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-канал):
- PhoneApp обнаруживает, что WebSocket-канал не поднялся (mDNS discovery не нашёл наш сервис).
- На
GlassesStatusScreenпоказывается кнопка «▶ Запустить наше приложение на очках». - По нажатию —
MercuryBridge.launchApp("pw.binom.rayneovm.glasses")через BLE-протокол RayNeo. - Очки сами открывают наше приложение (
Intent.PACKAGE_LAUNCHпо пакету). - GlassesApp стартует → поднимает WebSocket-сервер → mDNS publish → PhoneApp находит → подключается.
Запуск гостевого приложения (Zona) (через WebSocket-канал):
- При тапе на профиль в
AppsListScreenPhoneApp шлётLaunchAppCommand(packageName="free.zona")черезVmProtocol(WebSocket). - GlassesApp получает → VM-сервис запускает/фокусирует Activity (
IActivityTaskManager.startActivityAsUser+setLaunchDisplayId). - Если приложение уже запущено — просто фокусируем.
- Если не запущено — стартуем.
- PhoneApp переходит на
AppControlScreen(profile).
4.6.6. Статус очков (таб «Очки»)
Два независимых канала связи с очками:
| Канал | Технология | Назначение |
|---|---|---|
| Mercury-канал (служебный) | BLE, фирменный протокол RayNeo Mercury (pw.binom.mercury:library) |
Спаривание, статус очков (батарея/яркость/WiFi), запуск нашего GlassesApp по пакету через appMarketLaunch |
| WebSocket-канал (основной) | WiFi, наш :transport модуль |
Управление запущенным GlassesApp: тачи, клавиши, команды Zona |
Mercury-канал доступен всегда (когда очки спарены), даже если наше GlassesApp ещё не запущено. Используем его для:
- Проверки, что очки вообще доступны.
- Получения батареи/статуса.
- Запуска GlassesApp одной кнопкой (
MercuryBridge.launchApp("pw.binom.rayneovm.glasses")— очки сами открывают наш пакет). - Гашения/включения экрана очков, управления 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.
Без автоперезапуска (по решению пользователя):
- VM-сервис получает
onTaskDestroyedдля пакетаfree.zona. - Шлёт
AppCrashedEvent(packageName)через WebSocket в PhoneApp. AppControlViewModelловит событие:Snackbar«Zona упала»navController.popBackStack()→ возврат наAppsListScreen
- Пользователь сам решает — тапает на 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_EVENTSpermission — даём через 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: создаёт KtorCIOserver 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/TouchTypeenum сериализация.AudioMode/OverlayKindenum сериализация.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. План разработки
Объём первой ночи
Делаем всё, включая коммуникацию между устройствами. Утром — отладка.
Конкретно:
- Все модули
rayneo-mercury::transport,:binocular-renderer,:shizuku-vd(publish в Nexus). - Все модули
rayneo-vm::shared,:app-glasses-vm,:app-phone-vm. - Unit-тесты для всей бизнес-логики.
- Полная коммуникация: телефон находит очки по mDNS → WebSocket → UI управления Zona на телефоне → тач/клавиши → на очках → через VM-сервис →
injectInputEvent→ в Zona.
Этапы (последовательность)
Этап 0. Подготовка rayneo-mercury (~2ч)
:transport— перенести код из view-mate/lib-core, namespacepw.binom.mercury.transport, APITransport<T>/ServerTransport<T>+ DSLTransport.wifi(...)/.bt(...)/.wifiServer(...)/.btServer(). Зависимости: Ktor + kotlinx-serialization. Unit-тесты для DSL и контрактов.:binocular-renderer— обобщитьBinocularVideoPlayer.kt, интерфейсFrameSource+ реализацииVideoFrameSource(для view-mate, но реализацию делаем минимальной — заглушка, она view-mate нужна только для совместимости) иSurfaceTextureFrameSource(для rayneo-vm, основная).:shizuku-vd— AIDL-биндер + UserService обёртка (createVirtualDisplay,startActivityAsUser,injectInputEvent).- Опубликовать все три в локальный Nexus.
Этап 1. Bootstrap rayneo-vm (~1ч)
- Gradle-проект rayneo-vm с тремя модулями (
:shared,:app-glasses-vm,:app-phone-vm). gradle.properties,settings.gradle.kts, корневойbuild.gradle.kts(плагины: AGP, Kotlin, Compose, kotlinx-serialization).- Подключить зависимости из Nexus + Shizuku API + RayNeo SDK.
- Минимальные
MainActivityв обоих приложениях + build OK.
Этап 2. Модуль :shared (~30мин)
- DTO:
VmMessage(sealed),TouchEvent,TouchAction,KeyEvent,KeyAction,GlassesStatus,LaunchAppCommand,AudioModeCommand. - Сериализация (kotlinx-serialization, JSON).
- Unit-тесты: roundtrip сериализации, парсинг всех вариантов
VmMessage.
Этап 3. :app-glasses-vm — рендер (~1ч)
VmGlassesApp : Application(инициализация Shizuku listener).MainActivity(fullscreen + immersive).VmGlassesController— оркестратор.VirtualDisplayFrameSource : FrameSource— пока сSurfaceTextureот чёрного bitmap (заглушка).BinocularRenderer— рендерит в обе половины.- Unit-тесты для
FrameSourceконтракта.
Этап 4. :app-glasses-vm — VM-сервис и VirtualDisplay (~2ч)
- AIDL:
IVmShizukuServiceс методамиcreateVirtualDisplay()/launchApp(packageName)/injectInputEvent(event). VmShizukuUserService : UserService— реализация в отдельном процессе с правами shell:IDisplayManager.createVirtualDisplay(...)безOWN_CONTENT_ONLY.IActivityTaskManager.startActivityAsUser(...)дляfree.zona.InputManager.injectInputEvent(...)для инпута.
VmShizukuService(клиент) — биндинг черезShizuku.bindUserService.- Surface виртуального дисплея → через биндер в
MainActivity→VirtualDisplayFrameSource.
Этап 5. :app-phone-vm — UI (~2ч)
- Material 3 тёмная тема.
HomeScreen+HomeViewModel+ bottom navigation.GlassesStatusScreen+ ViewModel.AppsListScreen+ ViewModel +AppProfile.AppControlScreen+ ViewModel.ZonaProfile(тачпад + медиа-кнопки + клавиатура + назад).TouchpadView,MediaButton,KeyboardDialog,ConnectionStatus.VmPhoneController(транспорт, протокол, lifecycle).- Unit-тесты для ViewModel'ей и
VmProtocol.
Этап 6. Коммуникация (~2ч)
- В GlassesApp:
WifiServerTransport(поднимаем WebSocket-сервер на очках). - mDNS discovery на телефоне (через
:transportMdnsScanner). - В PhoneApp:
WifiTransport-клиент, подключение по найденному URL. VmProtocol— обёртка, кодит/декодит VmMessage.- Передача
TouchEvent/KeyEventPhone → Glasses → VM-сервис →injectInputEventв Zona. - Передача
LaunchAppCommand. - Авто-реконнект с экспоненциальным backoff + Snackbar.
- Unit-тесты: маппинг координат, формирование KeyEvent из символов, парсинг входящих сообщений.
Этап 7. Звук — режим 2 (~2ч, если успеем)
AudioCaptureService(AudioPlaybackCapture, USAGE_MEDIA).AudioStreamer(PCM → ByteTransport).AudioPlayer(ByteTransport → AudioTrack на телефоне).AudioModeCommandв протоколе.- Приглушение звука на очках в режиме 2.
Этап 8. Shizuku UX (~30мин)
ShizukuStatusChecker— проверка при старте.ShizukuActivationScreen— экран с инструкцией и кнопкой.- 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:
- Создание VirtualDisplay без
VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY. - Запуск чужой Activity на этом дисплее через
IActivityTaskManager.startActivityAsUser. - Передача инпута в гостевое приложение через
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→ Nexuscaffeine. - Подключение в 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). |