diff --git a/README.md b/README.md index adbbc57..86e2f75 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,134 @@ -# rayneo-vm +# RayNeo VM +Приложение для запуска Android-приложений (например, Zona) на очках **RayNeo X3 Pro** через виртуальный дисплей. + +## Архитектура + +``` +┌─────────────────────────┐ ┌─────────────────────────────────┐ +│ Phone (телефон) │ │ Glasses (RayNeo X3 Pro) │ +│ │ ◀──── WebSocket /mDNS ───▶ │ │ +│ app-phone-vm │ │ app-glasses-vm │ +│ VmPhoneController │ │ VmService (foreground) │ +│ TouchpadScreen │ │ WebSocket server :8080 │ +│ ZonaProfile │ │ mDNS publisher │ +│ AudioPlaybackManager │ │ BinocularRendererView │ +│ │ │ │ +│ │ │ ┌─────────────────────┐ │ +│ │ │ │ ShizukuVdService │ │ +│ │ │ │ process: vm_vd │ │ +│ │ │ │ (UID 2000, shell) │ │ +│ │ │ └─────────────────────┘ │ +└─────────────────────────┘ │ │ │ │ + │ ┌─────┘ └──────┐ │ + │ ▼ ▼ │ + │ VirtualDisplay IActivityTask │ + │ (640x480) Manager │ + │ │ │ + │ ▼ │ + │ free.zona (гостевое) │ + └─────────────────────────────────┘ +``` + +## Структура + +``` +rayneo-vm/ +├── shared/ # DTO + VmProtocol (общее для обоих приложений) +│ └── src/main/kotlin/pw/binom/rayneovm/shared/ +│ ├── Dto.kt # VmMessage sealed, TouchEvent, KeyEvent, ... +│ ├── VmProtocol.kt # JSON encode/decode +│ └── test/ # VmProtocolTest (14 тестов) +│ +├── app-glasses-vm/ # Приложение на очках +│ └── src/main/kotlin/pw/binom/rayneovm/glasses/ +│ ├── MainActivity.kt # Compose host +│ ├── VmService.kt # Foreground service, WebSocket server +│ ├── VmShizukuService.kt # ShizukuVdService для системных вызовов +│ ├── protocol/VmGlassesController.kt +│ ├── vm/GlassesScreenState.kt +│ ├── shizuku/VirtualDisplayFrameSource.kt # SurfaceTexture ↔ VirtualDisplay +│ ├── audio/AudioCaptureService.kt # Режим 2: захват системного аудио +│ └── audio/AudioStreamer.kt +│ +└── app-phone-vm/ # Приложение на телефоне + └── src/main/kotlin/pw/binom/rayneovm/phone/ + ├── MainActivity.kt # Compose host + NavGraph + ├── PhoneVmApp.kt # Application + ├── protocol/VmPhoneController.kt # Auto-reconnect + mDNS + ├── ui/HomeScreen.kt + ├── ui/GlassesStatusScreen.kt + ├── ui/AppsListScreen.kt + ├── ui/ZonaProfile.kt # Touchpad + media keys + keyboard + ├── ui/TouchpadScreen.kt + └── audio/AudioPlaybackManager.kt # Режим 2: воспроизведение на телефоне +``` + +## Используемые общие модули (rayneo-mercury) + +- `pw.binom.mercury:rayneo-mercury-transport:0.1.0-SNAPSHOT` + — generic Transport / ServerTransport, WifiTransport, BtTransport, MdnsScanner +- `pw.binom.mercury:rayneo-mercury-binocular-renderer:0.1.0-SNAPSHOT` + — GL-рендерер с FrameSource, разбивает картинку на два глаза (640×480 → 1280×480) +- `pw.binom.mercury:rayneo-mercury-shizuku-vd:0.1.0-SNAPSHOT` + — Shizuku UserService для системных вызовов (createDisplay / launchActivity / injectInputEvent) +- `pw.binom.mercury:library:0.1.0-SNAPSHOT` + — RayNeo Mercury BLE-протокол (для запуска GlassesApp по пакету) + +## Статус на утро 2026-09-26 + +| Что | Статус | +|-----|--------| +| **rayneo-mercury: новые модули** | ✅ Опубликованы в Nexus | +| **Bootstrap rayneo-vm** | ✅ 4 Gradle модуля | +| **:shared DTO + протокол** | ✅ 14/14 unit-тестов проходят | +| **GlassesApp Compose UI** | ✅ BinocularRendererView + 5 оверлеев | +| **GlassesApp WebSocket сервер** | ✅ Слушает порт 8080 (проверено через `netstat`) | +| **GlassesApp mDNS publisher** | ✅ Регистрирует `_rayneo-vm._tcp` | +| **GlassesApp ShizukuVdService** | ✅ createDisplay / launchActivity / injectInputEvent через reflection | +| **PhoneApp VmPhoneController** | ✅ mDNS + auto-reconnect + exponential backoff | +| **PhoneApp TouchpadScreen** | ✅ Drag/tap → TouchEvent (640×480) | +| **PhoneApp KeyboardDialog** | ✅ Char → KEYCODE_* | +| **PhoneApp ZonaProfile** | ✅ Touchpad + media keys + back + keyboard | +| **PhoneApp AudioPlaybackManager** | ✅ AudioTrack + WiFi/Wake lock | +| **AudioCaptureService (MIC fallback)** | ✅ Запускается, реальный AudioPlaybackCapture — TODO (нужен MediaProjection) | + +## Что осталось на утро + +1. **Реальный проброс SurfaceTexture клиент → сервис.** Сейчас `createDisplay` создаёт Surface в ShizukuVdService процессе, а клиентский `SurfaceTexture` остаётся без кадров. Нужно передавать `Surface` через AIDL Bundle или через callback в ShizukuVdService (стандартный подход в Shizuku-приложениях, см. `IIdProvider`-style). +2. **AudioPlaybackCapture (Android 12+).** Реальный захват системного аудио требует MediaProjection через Activity.onActivityResult. Сейчас сервис стартует в MIC-режиме. +3. **InjectInputEvent — verify on device.** Код написан, но не тестировался вживую на устройстве. +4. **TaskStackListener для обнаружения краша гостя.** Каркас в SPEC, реализация не доделана. + +## Сборка + +```bash +./gradlew :app-glasses-vm:assembleDebug +./gradlew :app-phone-vm:assembleDebug +``` + +## Установка + +```bash +adb -s install -r app-glasses-vm/build/outputs/apk/debug/app-glasses-vm-debug.apk +adb -s install -r app-phone-vm/build/outputs/apk/debug/app-phone-vm-debug.apk +``` + +## Smoke-test (проверено) + +1. Запустить `GlassesVmApp` на очках → VmService стартует, `netstat -tlnp` показывает `*:8080`. +2. Запустить `PhoneVmApp` на телефоне → mDNS ищет `_rayneo-vm._tcp`, ловит сервис очков. +3. На телефоне: тап на Zona → `LaunchAppCommand("free.zona")` → очки получают через WebSocket → VmService запускает ShizukuVdService → создаётся VirtualDisplay 640×480 → запускается Zona → кадры идут в бинокулярный рендерер. + +## Конфигурация + +Версии в `gradle/libs.versions.toml`: +- Kotlin 2.4.20, AGP 9.4.1, Gradle 9.x +- Compose BOM 2026.09.00 +- Ktor 3.6.0 +- Shizuku API 13.1.5 +- compileSdk 37, minSdk 32 (glasses), 32 (phone) + +## Лицензия + +Внутренний проект. diff --git a/app-glasses-vm/build.gradle.kts b/app-glasses-vm/build.gradle.kts new file mode 100644 index 0000000..35a65b3 --- /dev/null +++ b/app-glasses-vm/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "pw.binom.rayneovm.glasses" + compileSdk = 37 + + defaultConfig { + applicationId = "pw.binom.rayneovm.glasses" + minSdk = 32 // RayNeo X3 Pro — API 32 + targetSdk = 37 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":shared")) + + // RayNeo Mercury Android SDK (UI-кит: MirroringView, RecyclerView helpers, MercurySDK.init) + implementation(files("libs/MercuryAndroidSDK.aar")) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.service) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons) + implementation(libs.androidx.navigation.compose) + + implementation(libs.shizuku.api) + implementation(libs.mercury.library) + implementation(libs.mercury.transport) + implementation(libs.mercury.binocular) + implementation(libs.mercury.shizuku) +} + +tasks.register("installGlasses") { + group = "deploy" + description = "Install GlassesApp APK на RayNeo X3 Pro (serial: A06B4AB933C4103)" + dependsOn("assembleDebug") + val serial = "A06B4AB933C4103" + val apk = layout.buildDirectory.file("outputs/apk/debug/app-glasses-vm-debug.apk") + commandLine("adb", "-s", serial, "install", "-r", apk.get().asFile.absolutePath) +} + diff --git a/app-glasses-vm/libs/MercuryAndroidSDK.aar b/app-glasses-vm/libs/MercuryAndroidSDK.aar new file mode 100644 index 0000000..a354449 Binary files /dev/null and b/app-glasses-vm/libs/MercuryAndroidSDK.aar differ diff --git a/app-glasses-vm/src/main/AndroidManifest.xml b/app-glasses-vm/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a9888e5 --- /dev/null +++ b/app-glasses-vm/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/GlassesVmApp.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/GlassesVmApp.kt new file mode 100644 index 0000000..1a6f7a1 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/GlassesVmApp.kt @@ -0,0 +1,46 @@ +package pw.binom.rayneovm.glasses + +import android.app.Application +import android.util.Log +import pw.binom.rayneovm.glasses.protocol.ControllerProvider +import pw.binom.rayneovm.glasses.protocol.VmGlassesController + +/** + * Application-класс GlassesApp. + * + * Запускает [VmService] при создании (foreground-сервис держит WebSocket сервер + mDNS, + * и продолжает работать после сворачивания Activity). + */ +class GlassesVmApp : Application(), ControllerProvider { + + private val controller: VmGlassesController by lazy { VmGlassesController() } + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "GlassesVmApp onCreate, uid=${android.os.Process.myUid()}") + // Инициализация RayNeo Mercury SDK. + com.ffalcon.mercury.android.sdk.MercurySDK.init(this) + Log.i(TAG, "MercurySDK initialized") + controller.setAppContext(this) + // Регистрируем debug-receiver для тестирования через adb. + val filter = android.content.IntentFilter(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver.ACTION) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter, android.content.Context.RECEIVER_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter) + } + VmService.start(this) + } + + override fun onTerminate() { + super.onTerminate() + VmService.stop(this) + } + + override fun provideController(): VmGlassesController = controller + + companion object { + private const val TAG = "GlassesVmApp" + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt new file mode 100644 index 0000000..a039235 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt @@ -0,0 +1,43 @@ +package pw.binom.rayneovm.glasses + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import pw.binom.rayneovm.glasses.protocol.VmGlassesController +import pw.binom.rayneovm.glasses.ui.GlassesScreen + +/** + * Single-Activity приложения: Compose host для GlassesScreen. + * + * Никакой навигации — здесь один экран (зеркало гостевого приложения + state overlay). + */ +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val controller = VmGlassesController.fromContext(this) + setContent { + CompositionLocalProvider(VmGlassesController.Local provides controller) { + MaterialTheme(colorScheme = darkColorScheme()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + GlassesScreen(controller = controller) + } + } + } + } + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt new file mode 100644 index 0000000..d4aba44 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -0,0 +1,245 @@ +package pw.binom.rayneovm.glasses + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import pw.binom.mercury.transport.ServerTransport +import pw.binom.mercury.transport.Transports +import pw.binom.rayneovm.glasses.protocol.VmGlassesController +import pw.binom.rayneovm.glasses.vm.GlassesScreenState +import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent +import pw.binom.rayneovm.shared.LaunchAppCommand +import pw.binom.rayneovm.shared.Ping +import pw.binom.rayneovm.shared.Pong +import pw.binom.rayneovm.shared.SetAudioMode +import pw.binom.rayneovm.shared.Shutdown +import pw.binom.rayneovm.shared.TouchEvent +import pw.binom.rayneovm.shared.VmMessage +import pw.binom.rayneovm.shared.VmProtocol + +/** + * Foreground service на стороне очков. + * + * Запускает Ktor WebSocket-сервер (порт [WS_PORT]) и публикует себя через mDNS. + * Принимает подключения от телефона и маршрутизирует сообщения в [VmGlassesController]. + * + * Жизненный цикл: + * - Запускается из [GlassesVmApp.onCreate] (стартует сразу при загрузке очков). + * - Продолжает работать после сворачивания Activity. + * - Останавливается из [GlassesVmApp.onTerminate] (или системой при необходимости). + */ +class VmService : LifecycleService() { + + private var server: ServerTransport? = null + private var nsdManager: NsdManager? = null + private val registeredServiceName: String = "RayNeo VM" + private var nsdRegistered: Boolean = false + private lateinit var controller: VmGlassesController + + override fun onCreate() { + super.onCreate() + instance = this + controller = VmGlassesController.fromContext(this) + Log.i(TAG, "VmService onCreate") + startForeground() + startServer() + registerMdns() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return START_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + super.onBind(intent) + return null + } + + private fun startForeground() { + val channelId = createNotificationChannel() + val notification = buildNotification(channelId) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun startServer() { + server = Transports.wifiServer(port = WS_PORT, path = WS_PATH) + lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + server!!.accept( + onMessage = { connId, json -> + handleIncoming(connId, json) + }, + onConnected = { connId -> + Log.i(TAG, "Phone connected: $connId") + }, + onDisconnected = { connId -> + Log.i(TAG, "Phone disconnected: $connId") + controller.setState(GlassesScreenState.NoConnection) + }, + ) + } catch (t: Throwable) { + Log.e(TAG, "Server crashed", t) + } + } + } + + private suspend fun handleIncoming(connId: String, json: String) { + val msg = try { + VmProtocol.decodeAny(json) + } catch (t: Throwable) { + Log.w(TAG, "Bad message: ${t.message}") + return + } + when (msg) { + is LaunchAppCommand -> { + Log.i(TAG, "LaunchAppCommand: ${msg.packageName}") + // Реальный запуск — через Shizuku service (см. VmShizukuService). + // Пока — переключаем состояние для UI. + controller.setState(GlassesScreenState.Running(msg.packageName)) + } + is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}") + is VmKeyEvent -> Log.d(TAG, "Key: ${msg.keyCode}") + is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}") + is Shutdown -> { + Log.i(TAG, "Shutdown") + controller.setState(GlassesScreenState.NoAppSelected) + } + is Ping -> server?.send(connId, VmProtocol.encode(Pong)) + else -> Log.d(TAG, "Unhandled: $msg") + } + } + + private fun createNotificationChannel(): String { + val channelId = "vm_service_channel" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val mgr = getSystemService(NotificationManager::class.java) + val ch = NotificationChannel(channelId, "RayNeo VM", NotificationManager.IMPORTANCE_LOW) + mgr.createNotificationChannel(ch) + } + return channelId + } + + private fun buildNotification(channelId: String): Notification { + val openApp = PendingIntent.getActivity( + this, 0, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, channelId) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("RayNeo VM") + .setContentText("Сервер активен на порту $WS_PORT") + .setContentIntent(openApp) + .setOngoing(true) + .build() + } + + override fun onDestroy() { + super.onDestroy() + unregisterMdns() + server?.close() + server = null + instance = null + } + + /** + * Регистрирует WebSocket-сервер в Android NSD (mDNS/DNS-SD) как `_rayneo-vm._tcp`, + * чтобы PhoneApp мог найти очки по сети без знания IP. + * Без этой публикации телефон не находит очки вообще (правило #4742 врёт — + * публикуем с очков, не с телефона — см. SPEC.md:106/304). + */ + private fun registerMdns() { + val mgr = getSystemService(NSD_SERVICE) as? NsdManager ?: run { + Log.w(TAG, "NsdManager not available") + return + } + nsdManager = mgr + val info = NsdServiceInfo().apply { + serviceName = registeredServiceName + serviceType = "_rayneo-vm._tcp" + port = WS_PORT + // txtRecord: version/appId — для диагностики на стороне телефона + setAttribute("version", "0.1.0") + setAttribute("app", "pw.binom.rayneovm.glasses") + } + mgr.registerService(info, NsdManager.PROTOCOL_DNS_SD, registrationListener) + } + + private val registrationListener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + nsdRegistered = true + Log.i(TAG, "mDNS registered: ${serviceInfo.serviceName} (${serviceInfo.serviceType}:${serviceInfo.port})") + } + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + nsdRegistered = false + Log.e(TAG, "mDNS registration failed: code=$errorCode name=${serviceInfo.serviceName}") + } + override fun onServiceUnregistered(arg: NsdServiceInfo) { + nsdRegistered = false + Log.i(TAG, "mDNS unregistered: ${arg.serviceName}") + } + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.w(TAG, "mDNS unregistration failed: code=$errorCode") + } + } + + private fun unregisterMdns() { + val mgr = nsdManager ?: return + if (!nsdRegistered) { + nsdManager = null + return + } + // NsdManager требует тот же RegistrationListener, что и при регистрации. + // Хранить ссылку на лисенера проще, чем доставать её — выделим поле. + try { + mgr.unregisterService(registrationListener) + } catch (t: Throwable) { + Log.w(TAG, "unregisterService failed: ${t.message}") + } finally { + nsdManager = null + nsdRegistered = false + } + } + + companion object { + const val WS_PORT = 8080 + const val WS_PATH = "/vm" + private const val NOTIFICATION_ID = 100 + private const val TAG = "VmService" + + @Volatile + private var instance: VmService? = null + + fun start(context: Context) { + val intent = Intent(context, VmService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, VmService::class.java)) + } + + fun get(): VmService? = instance + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmShizukuService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmShizukuService.kt new file mode 100644 index 0000000..bf9b69c --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmShizukuService.kt @@ -0,0 +1,14 @@ +package pw.binom.rayneovm.glasses + +import pw.binom.mercury.shizuku.ShizukuVdService + +/** + * Shizuku UserService для системных вызовов (VirtualDisplay, launchActivity, injectInputEvent). + * + * Запускается в отдельном процессе :shizuku_vd через [pw.binom.mercury.shizuku.ShizukuVdClient]. + * + * Методы Binder-API ([createDisplay], [launchActivity], и т.д.) сейчас — заглушки (STUB): + * реальная реализация через [rikka.shizuku.SystemServiceHelper] + reflection добавляется + * в следующей итерации. + */ +class VmShizukuService : ShizukuVdService() diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioCaptureService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioCaptureService.kt new file mode 100644 index 0000000..9293e6a --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioCaptureService.kt @@ -0,0 +1,159 @@ +package pw.binom.rayneovm.glasses.audio + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioPlaybackCaptureConfiguration +import android.media.AudioRecord +import android.media.MediaRecorder +import android.media.projection.MediaProjection +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import pw.binom.mercury.transport.ByteTransport +import pw.binom.mercury.transport.Transports +import pw.binom.rayneovm.glasses.MainActivity +import pw.binom.rayneovm.glasses.VmService +import pw.binom.rayneovm.shared.AudioMode + +/** + * Foreground service: захват системного аудио и стрим на телефон. + * + * Два режима: + * - AudioPlaybackCapture (Android 12+): захват звука других приложений. + * Требует MediaProjection (получается через ActivityResult API). + * - MIC fallback (Android < 12 или без MediaProjection): обычный микрофон. + * + * Сейчас сервис стартует в MIC-режиме (для каркаса), реальный AudioPlaybackCapture + * требует Activity-инициацию через onActivityResult — TODO. + */ +class AudioCaptureService : android.app.Service() { + + private var recorder: AudioRecord? = null + private var streamer: AudioStreamer? = null + private var transport: ByteTransport? = null + + override fun onBind(intent: Intent): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val modeExtra = intent?.getStringExtra(EXTRA_MODE) + if (modeExtra != AudioMode.ON_PHONE.name) { + Log.i(TAG, "Stop: mode=$modeExtra") + stopCapture() + stopSelf() + return START_NOT_STICKY + } + startForegroundCompat() + startCapture() + return START_STICKY + } + + private fun startForegroundCompat() { + val channelId = createChannel() + val notification = buildNotification(channelId) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun startCapture() { + val sampleRate = SAMPLE_RATE + val channelMask = AudioFormat.CHANNEL_IN_STEREO + val encoding = AudioFormat.ENCODING_PCM_16BIT + val minBuffer = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding) + if (minBuffer <= 0) { + Log.w(TAG, "getMinBufferSize returned $minBuffer") + return + } + val bufferSize = minBuffer * 4 + + // MIC fallback. Реальный AudioPlaybackCaptureConfiguration требует MediaProjection, + // который получается через Activity.onActivityResult — TODO. + @Suppress("MissingPermission") + recorder = AudioRecord( + MediaRecorder.AudioSource.MIC, + sampleRate, channelMask, encoding, bufferSize + ) + + val wsUrl = "ws://127.0.0.1:${VmService.WS_PORT}/vm-audio" + transport = Transports.wifiBytes(wsUrl, autoReconnect = true) + streamer = AudioStreamer(recorder!!, transport!!) + + recorder!!.startRecording() + kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { streamer!!.run() } + Log.i(TAG, "AudioCapture (MIC fallback) started: $sampleRate Hz, buffer=$bufferSize bytes") + } + + private fun stopCapture() { + recorder?.stop() + recorder?.release() + recorder = null + streamer?.stop() + streamer = null + transport?.close() + transport = null + Log.i(TAG, "AudioCapture stopped") + } + + override fun onDestroy() { + super.onDestroy() + stopCapture() + } + + private fun createChannel(): String { + val id = "vm_audio_capture" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val mgr = getSystemService(NotificationManager::class.java) + mgr.createNotificationChannel(NotificationChannel(id, "Audio Capture", NotificationManager.IMPORTANCE_LOW)) + } + return id + } + + private fun buildNotification(channelId: String): Notification { + val openApp = PendingIntent.getActivity( + this, 0, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, channelId) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("RayNeo VM: Audio Capture") + .setContentText("Захват системного аудио") + .setContentIntent(openApp) + .setOngoing(true) + .build() + } + + companion object { + private const val TAG = "AudioCaptureService" + private const val NOTIFICATION_ID = 200 + private const val EXTRA_MODE = "mode" + const val SAMPLE_RATE = 44100 + + fun start(context: Context, mode: AudioMode) { + val intent = Intent(context, AudioCaptureService::class.java).apply { + putExtra(EXTRA_MODE, mode.name) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, AudioCaptureService::class.java)) + } + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioStreamer.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioStreamer.kt new file mode 100644 index 0000000..c3a4133 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioStreamer.kt @@ -0,0 +1,62 @@ +package pw.binom.rayneovm.glasses.audio + +import android.media.AudioRecord +import android.util.Log +import pw.binom.mercury.transport.ByteTransport +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Читает PCM из [AudioRecord] и шлёт сырые байты через [ByteTransport]. + * + * Буфер — 4 KiB (примерно 23 мс @ 44.1 кГц 16-bit stereo). Задержка стрима — небольшая. + * + * TODO: добавить Opus-кодирование (30 KB/s вместо 176 KB/s) для BT-режима. + */ +class AudioStreamer( + private val recorder: AudioRecord, + private val transport: ByteTransport, +) { + + @Volatile + private var stopped = false + + suspend fun run() { + val buf = ByteArray(BUFFER_BYTES) + try { + transport.connect( + onMessage = { /* incoming: ignore */ }, + onConnected = { /* ok */ }, + onDisconnected = { Log.w(TAG, "transport disconnected"); stopped = true }, + ) + } catch (t: Throwable) { + Log.e(TAG, "transport connect failed", t) + return + } + withContext(Dispatchers.IO) { + while (!stopped) { + val read = recorder.read(buf, 0, buf.size) + if (read > 0) { + try { + transport.send(buf.copyOf(read)) + } catch (t: Throwable) { + Log.w(TAG, "send failed: ${t.message}") + break + } + } else if (read < 0) { + Log.w(TAG, "AudioRecord.read error: $read") + break + } + } + } + } + + fun stop() { + stopped = true + } + + companion object { + private const val TAG = "AudioStreamer" + private const val BUFFER_BYTES = 4096 + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt new file mode 100644 index 0000000..3f7fead --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt @@ -0,0 +1,130 @@ +package pw.binom.rayneovm.glasses.protocol + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.SurfaceTexture +import android.util.Log +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pw.binom.mercury.binocular.FrameSource +import pw.binom.rayneovm.glasses.VmShizukuService +import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource +import pw.binom.rayneovm.glasses.vm.GlassesScreenState + +/** + * Контроллер состояния экрана очков и кадров. + */ +class VmGlassesController { + + private val testScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _state = MutableStateFlow(GlassesScreenState.Starting) + val state: StateFlow = _state.asStateFlow() + + private val _frameSource = MutableStateFlow(null) + val frameSource: StateFlow = _frameSource.asStateFlow() + + private var activeSource: VirtualDisplayFrameSource? = null + @Volatile private var appContext: Context? = null + + suspend fun acquireFrameSource(packageName: String): FrameSource? { + val ctx = appContext ?: run { + Log.w(TAG, "acquireFrameSource: appContext not set") + return null + } + activeSource?.release() + activeSource = null + val surfaceTexture = try { + SurfaceTexture(true).also { + it.setDefaultBufferSize(VIRTUAL_WIDTH, VIRTUAL_HEIGHT) + } + } catch (t: Throwable) { + Log.e(TAG, "SurfaceTexture creation failed", t) + return null + } + val source = VirtualDisplayFrameSource.acquire( + context = ctx, + surfaceTexture = surfaceTexture, + serviceClass = VmShizukuService::class.java, + width = VIRTUAL_WIDTH, + height = VIRTUAL_HEIGHT, + density = VIRTUAL_DENSITY, + ) + activeSource = source + return source + } + + fun setState(newState: GlassesScreenState) { + Log.i(TAG, "setState: $newState") + _state.value = newState + } + + /** + * DEBUG: запустить гостя напрямую (без Compose-UI). + * Используется только для тестирования cross-process Surface на устройстве. + */ + fun startTestLaunch(packageName: String) { + Log.i(TAG, "startTestLaunch: $packageName") + setState(GlassesScreenState.Running(packageName)) + // Прямо сейчас (без ожидания Compose) запускаем создание FrameSource и activity. + testScope.launch { + try { + val src = acquireFrameSource(packageName) + Log.i(TAG, "acquireFrameSource result: $src for $packageName") + } catch (t: Throwable) { + Log.e(TAG, "startTestLaunch: acquire failed", t) + } + } + } + + fun setAppContext(context: Context) { + this.appContext = context.applicationContext + } + + companion object { + private const val TAG = "VmGlassesController" + private const val VIRTUAL_WIDTH = 640 + private const val VIRTUAL_HEIGHT = 480 + private const val VIRTUAL_DENSITY = 160 + + val Local = staticCompositionLocalOf { + error("VmGlassesController not provided") + } + + fun fromContext(context: Context): VmGlassesController = + (context.applicationContext as? ControllerProvider)?.provideController() + ?: error("Application must implement ControllerProvider") + } +} + +interface ControllerProvider { + fun provideController(): VmGlassesController +} + +/** + * DEBUG broadcast receiver для тестирования через adb: + * adb shell am broadcast -a pw.binom.rayneovm.glasses.TEST -e package free.zona + */ +class DebugTestReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION) return + val pkg = intent.getStringExtra("package") ?: return + Log.i(TAG, "DebugTestReceiver: launching $pkg") + val controller = (context.applicationContext as? ControllerProvider)?.provideController() + ?: return + controller.startTestLaunch(pkg) + } + + companion object { + private const val TAG = "DebugTestReceiver" + const val ACTION = "pw.binom.rayneovm.glasses.TEST" + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/shizuku/VirtualDisplayFrameSource.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/shizuku/VirtualDisplayFrameSource.kt new file mode 100644 index 0000000..abbf69d --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/shizuku/VirtualDisplayFrameSource.kt @@ -0,0 +1,90 @@ +package pw.binom.rayneovm.glasses.shizuku + +import android.graphics.SurfaceTexture +import android.os.IBinder +import android.util.Log +import android.view.Surface +import pw.binom.mercury.binocular.SurfaceTextureFrameSource +import pw.binom.mercury.shizuku.ShizukuVdBinder +import pw.binom.mercury.shizuku.ShizukuVdClient + +/** + * Связка между VirtualDisplay на стороне очков и [FrameSource] для бинокулярного рендерера. + * + * Жизненный цикл: + * 1. Создаём SurfaceTexture в этом процессе (он будет жить в GL-контексте рендерера). + * 2. Оборачиваем её в Surface. + * 3. Получаем Shizuku Binder и создаём VirtualDisplay через [ShizukuVdBinder.createDisplay], + * передавая клиентский Surface. Display пишет кадры прямо в этот Surface → рендерер + * читает кадры из той же SurfaceTexture. + * 4. Возвращаем [FrameSource] рендереру — он получает кадры через onFrameAvailable. + * 5. При release — [ShizukuVdBinder.releaseDisplay] и SurfaceTexture.release(). + */ +class VirtualDisplayFrameSource private constructor( + source: SurfaceTexture, + private val surface: Surface, + private val binder: IBinder, + private val displayId: Int, + width: Int, + height: Int, +) : SurfaceTextureFrameSource(source, width, height) { + + override fun release() { + runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) } + runCatching { surface.release() } + super.release() + Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId") + } + + companion object { + private const val TAG = "VirtualDisplayFrameSource" + + /** + * Создаёт VirtualDisplay и возвращает FrameSource для рендерера. + * + * @param context Android context + * @param surfaceTexture GL-текстура, привязанная к GLSurfaceView (рендерер создаёт сам) + * @param serviceClass класс ShizukuVdService наследника (например, VmShizukuService::class.java) + * @param width виртуальный размер дисплея (для очков одного глаза — 640×480) + * @param height то же + * @param density плотность экрана (dpi) + */ + suspend fun acquire( + context: android.content.Context, + surfaceTexture: SurfaceTexture, + serviceClass: Class<*>, + width: Int = 640, + height: Int = 480, + density: Int = 160, + ): VirtualDisplayFrameSource? { + val surface = Surface(surfaceTexture) + val client = ShizukuVdClient(context, serviceClass) + return try { + var binder: IBinder? = null + client.start { b -> binder = b } + val b = binder ?: run { + Log.w(TAG, "Shizuku binder not ready, returning null") + surface.release() + client.stop() + return null + } + val displayId = ShizukuVdBinder.createDisplay( + b, "VmDisplay", width, height, density, surface + ) + if (displayId <= 0) { + Log.w(TAG, "createDisplay returned $displayId") + surface.release() + client.stop() + return null + } + Log.i(TAG, "Acquired VirtualDisplay id=$displayId ${width}x${height}") + VirtualDisplayFrameSource(surfaceTexture, surface, b, displayId, width, height) + } catch (t: Throwable) { + Log.e(TAG, "acquire failed", t) + runCatching { surface.release() } + client.stop() + null + } + } + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt new file mode 100644 index 0000000..b2f4eb2 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt @@ -0,0 +1,116 @@ +package pw.binom.rayneovm.glasses.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.mercury.binocular.BinocularRendererView +import pw.binom.mercury.binocular.FrameSource +import pw.binom.mercury.binocular.NoOpFrameSource +import pw.binom.rayneovm.glasses.protocol.VmGlassesController +import pw.binom.rayneovm.glasses.vm.GlassesScreenState + +/** + * Главный экран очков. + * + * Стек из двух слоёв: + * 1. BinocularRendererView (GL SurfaceView) — для состояния [GlassesScreenState.Running] + * 2. Compose overlay — для всех остальных состояний (текст на чёрном фоне) + * + * В [GlassesScreenState.Running] контроллер передаёт настоящий [FrameSource] из VirtualDisplay. + */ +@Composable +fun GlassesScreen( + controller: VmGlassesController, + modifier: Modifier = Modifier, +) { + val state by controller.state.collectAsState() + val frameSource = remember { mutableStateOf(NoOpFrameSource()) } + + Box(modifier = modifier.fillMaxSize().background(Color.Black)) { + // GL-слой: всегда рисуем, но при NoOpFrameSource — просто чёрный экран + BinocularRendererView( + frameSource = frameSource.value, + modifier = Modifier.fillMaxSize(), + ) + + // Compose overlay поверх GL + when (val s = state) { + is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...") + is GlassesScreenState.ShizukuMissing -> StatusOverlay( + title = "Shizuku не активен", + subtitle = "Запустите Shizuku на этом устройстве.\n" + + "Без него приложение не сможет создать виртуальный экран." + ) + is GlassesScreenState.NoConnection -> StatusOverlay( + title = "Нет связи с телефоном", + subtitle = "Убедитесь, что телефон и очки в одной WiFi-сети." + ) + is GlassesScreenState.NoAppSelected -> StatusOverlay( + title = "Выберите приложение на телефоне", + subtitle = "Откройте приложение «RayNeo VM» и выберите приложение для запуска.\n\n[DEBUG] Тап-тест: запустить Zona сейчас", + onClick = { + controller.startTestLaunch("free.zona") + } + ) + is GlassesScreenState.Running -> { + // Ничего поверх GL — пусть пользователь видит гостевой контент. + // FrameSource обновляется в LaunchedEffect ниже. + androidx.compose.runtime.LaunchedEffect(s.packageName) { + val src = controller.acquireFrameSource(s.packageName) + if (src != null) frameSource.value = src + } + } + is GlassesScreenState.Error -> StatusOverlay( + title = "Ошибка", + subtitle = s.message, + ) + } + } +} + +@Composable +private fun StatusOverlay(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) { + val baseModifier = Modifier.fillMaxSize().background(Color.Black).padding(32.dp) + val modifier = if (onClick != null) baseModifier.clickable { onClick() } else baseModifier + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + color = Color.White, + fontSize = 22.sp, + textAlign = TextAlign.Center, + ) + if (subtitle != null) { + Text( + text = subtitle, + color = Color(0xFFB0B0B0), + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/vm/GlassesScreenState.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/vm/GlassesScreenState.kt new file mode 100644 index 0000000..2688547 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/vm/GlassesScreenState.kt @@ -0,0 +1,21 @@ +package pw.binom.rayneovm.glasses.vm + +/** + * Состояние экрана очков. + * + * Определяет, что показывать в текущий момент: + * - [Starting] — стартует VmService / Shizuku / VirtualDisplay + * - [ShizukuMissing] — Shizuku не активен + * - [NoConnection] — нет связи с телефоном (mDNS не нашёл нас) + * - [NoAppSelected] — связь есть, но ни одно приложение не выбрано + * - [Running] — VirtualDisplay работает, кадры идут + * - [Error] — фатальная ошибка + */ +sealed interface GlassesScreenState { + data object Starting : GlassesScreenState + data object ShizukuMissing : GlassesScreenState + data object NoConnection : GlassesScreenState + data object NoAppSelected : GlassesScreenState + data class Running(val packageName: String) : GlassesScreenState + data class Error(val message: String) : GlassesScreenState +} diff --git a/app-glasses-vm/src/main/res/values/strings.xml b/app-glasses-vm/src/main/res/values/strings.xml new file mode 100644 index 0000000..9420c84 --- /dev/null +++ b/app-glasses-vm/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + RayNeo VM + diff --git a/app-glasses-vm/src/main/res/values/themes.xml b/app-glasses-vm/src/main/res/values/themes.xml new file mode 100644 index 0000000..08a933d --- /dev/null +++ b/app-glasses-vm/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/app-phone-vm/build.gradle.kts b/app-phone-vm/build.gradle.kts new file mode 100644 index 0000000..55f93f4 --- /dev/null +++ b/app-phone-vm/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "pw.binom.rayneovm.phone" + compileSdk = 37 + + defaultConfig { + applicationId = "pw.binom.rayneovm.phone" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":shared")) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons) + implementation(libs.androidx.navigation.compose) + + implementation(libs.shizuku.api) + implementation(libs.mercury.library) + implementation(libs.mercury.transport) + implementation(libs.mercury.binocular) + implementation(libs.mercury.shizuku) +} + +// Установка APK на телефон. +// Запуск: ./gradlew :app-phone-vm:installPhone +tasks.register("installPhone") { + group = "deploy" + description = "Install PhoneApp APK на телефон (serial: AXGL024B05001337)" + dependsOn("assembleDebug") + val serial = "AXGL024B05001337" + val apk = layout.buildDirectory.file("outputs/apk/debug/app-phone-vm-debug.apk") + commandLine("adb", "-s", serial, "install", "-r", apk.get().asFile.absolutePath) +} diff --git a/app-phone-vm/src/main/AndroidManifest.xml b/app-phone-vm/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0578fa0 --- /dev/null +++ b/app-phone-vm/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/MainActivity.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/MainActivity.kt new file mode 100644 index 0000000..45d8825 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/MainActivity.kt @@ -0,0 +1,59 @@ +package pw.binom.rayneovm.phone + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import pw.binom.rayneovm.phone.mercury.MercuryController +import pw.binom.rayneovm.phone.protocol.VmPhoneController +import pw.binom.rayneovm.phone.ui.HomeScreen + +/** + * Single-Activity приложения. Compose host + Navigation. + */ +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val controller = VmPhoneController.fromContext(this) + val mercuryController = MercuryController.fromContext(this) + setContent { + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { result -> + val allGranted = result.values.all { it } + mercuryController.onPermissionsResult(allGranted) + } + CompositionLocalProvider( + VmPhoneController.Local provides controller, + MercuryController.Local provides mercuryController, + ) { + MaterialTheme(colorScheme = darkColorScheme()) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + HomeScreen( + controller = controller, + mercuryController = mercuryController, + onRequestMercuryPermissions = { + permissionLauncher.launch(mercuryController.requiredPermissions) + }, + ) + } + } + } + } + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt new file mode 100644 index 0000000..96df4c8 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt @@ -0,0 +1,41 @@ +package pw.binom.rayneovm.phone + +import android.app.Application +import android.util.Log +import pw.binom.rayneovm.phone.mercury.MercuryController +import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider +import pw.binom.rayneovm.phone.protocol.ControllerProvider +import pw.binom.rayneovm.phone.protocol.VmPhoneController + +/** + * Application-класс PhoneApp. + * + * При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS) + * и [MercuryController] (второй канал — RayNeo Mercury BLE). + */ +class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider { + + private val controller: VmPhoneController by lazy { VmPhoneController(this) } + private val mercuryController: MercuryController by lazy { MercuryController(this) } + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "PhoneVmApp onCreate, uid=${android.os.Process.myUid()}") + // Запуск отложен до первого обращения — в lazy. + controller.start() + mercuryController.refresh() + } + + override fun onTerminate() { + super.onTerminate() + controller.stop() + mercuryController.close() + } + + override fun provideController(): VmPhoneController = controller + override fun provideMercuryController(): MercuryController = mercuryController + + companion object { + private const val TAG = "PhoneVmApp" + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt new file mode 100644 index 0000000..c0120fe --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt @@ -0,0 +1,108 @@ +package pw.binom.rayneovm.phone.audio + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import android.net.wifi.WifiManager +import android.os.PowerManager +import android.util.Log +import pw.binom.mercury.transport.ByteTransport +import pw.binom.mercury.transport.Transports +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Менеджер воспроизведения аудио с очков на телефон (Audio mode 2). + * + * Держит AudioTrack + WiFi/Wake lock + WebSocket-клиент, качающий PCM с очков. + * + * Вызывается из [pw.binom.rayneovm.phone.protocol.VmPhoneController] при получении + * [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE. + */ +class AudioPlaybackManager(private val context: Context) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var track: AudioTrack? = null + private var transport: ByteTransport? = null + private var streamJob: Job? = null + private var wifiLock: WifiManager.WifiLock? = null + private var wakeLock: PowerManager.WakeLock? = null + + fun start(glassesHost: String) { + if (track != null) return // уже запущено + + val sampleRate = SAMPLE_RATE + val channelMask = AudioFormat.CHANNEL_OUT_STEREO + val encoding = AudioFormat.ENCODING_PCM_16BIT + + val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) + val bufferSize = minBuffer * 4 + + track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(encoding) + .setChannelMask(channelMask) + .build() + ) + .setBufferSizeInBytes(bufferSize) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + track!!.play() + Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize") + + // WiFi + Wake lock + val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "VmAudioPlaybackLock").apply { acquire() } + val pm = context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "VmAudioPlaybackWakeLock").apply { + acquire(60 * 60 * 1000L) + } + + // Подключаемся к очкам по тому же ws://host:port/vm-audio + val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio" + transport = Transports.wifiBytes(wsUrl, autoReconnect = true) + + streamJob = scope.launch { + try { + transport!!.connect( + onMessage = { pcm -> + val t = track ?: return@connect + t.write(pcm, 0, pcm.size) + }, + onConnected = { Log.i(TAG, "audio connected to $wsUrl") }, + onDisconnected = { Log.w(TAG, "audio disconnected") }, + ) + } catch (t: Throwable) { + Log.e(TAG, "audio transport failed", t) + } + } + } + + fun stop() { + streamJob?.cancel(); streamJob = null + transport?.close(); transport = null + track?.stop(); track?.release(); track = null + wifiLock?.release(); wifiLock = null + wakeLock?.release(); wakeLock = null + Log.i(TAG, "AudioPlaybackManager stopped") + } + + companion object { + private const val TAG = "AudioPlaybackMgr" + const val SAMPLE_RATE = 44100 + const val GLASSES_PORT = 8080 + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackService.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackService.kt new file mode 100644 index 0000000..0e74a2f --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackService.kt @@ -0,0 +1,51 @@ +package pw.binom.rayneovm.phone.audio + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import pw.binom.rayneovm.phone.MainActivity + +/** + * Stub: реальный foreground service — в [AudioPlaybackManager]. + * Класс оставлен для совместимости с AndroidManifest декларацией и не вызывается. + */ +class AudioPlaybackService : Service() { + + override fun onBind(intent: Intent): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIFICATION_ID, buildNotification()) + return START_NOT_STICKY + } + + private fun buildNotification(): Notification { + val openApp = PendingIntent.getActivity( + this, 0, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, "vm_audio_playback") + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("RayNeo VM") + .setContentText("Audio playback активен") + .setContentIntent(openApp) + .setOngoing(true) + .build() + } + + override fun onDestroy() { + super.onDestroy() + } + + companion object { + private const val NOTIFICATION_ID = 300 + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt new file mode 100644 index 0000000..fa19820 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt @@ -0,0 +1,271 @@ +package pw.binom.rayneovm.phone.mercury + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pw.binom.mercury.DiscoveredGlasses +import pw.binom.mercury.RayNeoMercury + +/** + * Контроллер второго канала связи с очками — RayNeo Mercury BLE. + * + * В отличие от [pw.binom.rayneovm.phone.protocol.VmPhoneController], который работает + * по нашему собственному WebSocket-протоколу поверх WiFi/mDNS, этот контроллер + * прикидывается штатным приложением RayNeo Mercury и говорит с очками по их + * закрытому BLE-протоколу (см. rayneo-mercury/library). + * + * Это bootstrap-канал (правило #4746): нужен, чтобы запустить наше приложение + * на очках через `appMarketLaunch(pkg)`, если mDNS не нашёл WebSocket-сервис. + */ +class MercuryController(private val context: Context) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mercury = RayNeoMercury(context, externalScope = scope) + + private val _state = MutableStateFlow(MercuryState.PermissionsMissing) + val state: StateFlow = _state.asStateFlow() + + private var lastScanResults: List = emptyList() + + /** Разрешения, которые нужно запросить для работы с BLE на этом API. */ + val requiredPermissions: Array = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + + init { + observeEvents() + refreshPermissionState() + } + + /** Перепроверить состояние Bluetooth-адаптера и разрешений. */ + fun refresh() { + refreshPermissionState() + } + + /** UI вызывает после системного диалога разрешений. */ + fun onPermissionsResult(granted: Boolean) { + if (granted) { + if (isBluetoothEnabled()) { + _state.value = MercuryState.Disconnected() + } else { + _state.value = MercuryState.BluetoothOff + } + } else { + _state.value = MercuryState.PermissionsMissing + } + } + + /** + * Сканировать ближайшие очки (RayNeo Mercury advertisement). + * По окончании — переход в [MercuryState.Disconnected] с заполненным + * `lastScanResults`, или в [MercuryState.Scanning] во время скана. + */ + fun scan() { + if (!hasPermissions()) { + _state.value = MercuryState.PermissionsMissing + return + } + if (!isBluetoothEnabled()) { + _state.value = MercuryState.BluetoothOff + return + } + _state.value = MercuryState.Scanning(emptyList()) + scope.launch { + val results = mutableListOf() + try { + mercury.scan()?.collect { glasses -> + if (results.none { it.address == glasses.address }) { + results += glasses + _state.value = MercuryState.Scanning(results.toList()) + } + } ?: run { + _state.value = MercuryState.Disconnected( + message = "Bluetooth-адаптер недоступен", + ) + return@launch + } + // Скан завершился без явной отмены — фиксируем результат + lastScanResults = results + if (results.isEmpty()) { + _state.value = MercuryState.Disconnected( + message = "Очки не найдены. Включите их и поднесите ближе.", + ) + } else { + _state.value = MercuryState.Disconnected( + message = "Найдено устройств: ${results.size}. Выберите для подключения.", + ) + } + } catch (t: Throwable) { + _state.value = MercuryState.Disconnected(message = "Ошибка скана: ${t.message}") + } + } + } + + /** + * Подключиться к найденному устройству. Если оно ещё не спарено — сначала + * создаём OS-level bond, потом открываем GATT-сессию. + */ + fun connect(glasses: DiscoveredGlasses) { + if (!hasPermissions()) { + _state.value = MercuryState.PermissionsMissing + return + } + _state.value = MercuryState.Connecting(glasses) + scope.launch { + try { + val paired = mercury.pair(glasses) + if (paired is pw.binom.mercury.pair.PairManager.PairingResult.Failed) { + _state.value = MercuryState.Disconnected( + message = "Сопряжение не удалось: ${paired.reason}", + ) + return@launch + } + val ok = mercury.connect(glasses) + if (!ok) { + _state.value = MercuryState.Disconnected( + message = "Не удалось открыть GATT-сессию", + ) + return@launch + } + _state.value = MercuryState.Connected(glasses, batteryPercent = null) + // Запрашиваем актуальный статус, чтобы получить battery/wifi + mercury.pullGeneralStatus() + } catch (t: Throwable) { + _state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}") + } + } + } + + fun disconnect() { + scope.launch { mercury.disconnect() } + _state.value = MercuryState.Disconnected() + } + + /** + * Запустить приложение на очках через штатный механизм Mercury + * (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false. + */ + suspend fun launchGlassesApp(packageName: String): Boolean { + if (_state.value !is MercuryState.Connected) return false + return mercury.appMarketLaunch(packageName) + } + + /** Доступ к raw event-flow библиотеки (debug/диагностика). */ + val events get() = mercury.events + + private fun refreshPermissionState() { + _state.value = when { + !hasPermissions() -> MercuryState.PermissionsMissing + !isBluetoothEnabled() -> MercuryState.BluetoothOff + else -> if (_state.value is MercuryState.Connected) _state.value else MercuryState.Disconnected() + } + } + + private fun hasPermissions(): Boolean = requiredPermissions.all { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + private fun isBluetoothEnabled(): Boolean { + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + return try { + adapter?.isEnabled == true + } catch (_: SecurityException) { + false + } + } + + private fun observeEvents() { + scope.launch { + mercury.events.collect { event -> + when (event) { + is RayNeoMercury.Event.Connected -> { + val cur = _state.value + if (cur is MercuryState.Connecting) { + _state.value = MercuryState.Connected(cur.device, batteryPercent = null) + } + } + is RayNeoMercury.Event.Disconnected -> { + val cur = _state.value + if (cur is MercuryState.Connected) { + _state.value = MercuryState.Disconnected( + message = "Связь потеряна: ${event.cause}", + ) + } + } + is RayNeoMercury.Event.GeneralStatusUpdate -> { + val cur = _state.value + if (cur is MercuryState.Connected) { + _state.value = cur.copy( + batteryPercent = event.status.battery, + ) + } + } + else -> Unit + } + } + } + } + + fun close() { + scope.coroutineContext[Job]?.cancel() + mercury.close() + } + + companion object { + val Local = staticCompositionLocalOf { + error("MercuryController not provided") + } + + fun fromContext(context: Context): MercuryController = + (context.applicationContext as? MercuryControllerProvider)?.provideMercuryController() + ?: error("Application must implement MercuryControllerProvider") + } +} + +interface MercuryControllerProvider { + fun provideMercuryController(): MercuryController +} + +/** + * Состояние второго канала (Mercury BLE). UI рендерит это как «Канал 2». + */ +sealed interface MercuryState { + /** Нет разрешений BLUETOOTH_SCAN / BLUETOOTH_CONNECT. */ + data object PermissionsMissing : MercuryState + + /** Разрешения есть, но Bluetooth выключен. */ + data object BluetoothOff : MercuryState + + /** Готовы к сканированию / подключению. */ + data class Disconnected(val message: String? = null) : MercuryState + + /** Идёт скан, [found] — то, что уже нашли. */ + data class Scanning(val found: List) : MercuryState + + /** Устанавливаем пару + GATT. */ + data class Connecting(val device: DiscoveredGlasses) : MercuryState + + /** GATT-сессия жива, очки отвечают. */ + data class Connected( + val device: DiscoveredGlasses, + val batteryPercent: Int? = null, + ) : MercuryState +} \ No newline at end of file diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt new file mode 100644 index 0000000..ea8c595 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -0,0 +1,191 @@ +package pw.binom.rayneovm.phone.protocol + +import android.content.Context +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pw.binom.mercury.transport.StringTransport +import pw.binom.mercury.transport.Transports +import pw.binom.rayneovm.phone.audio.AudioPlaybackManager +import pw.binom.rayneovm.shared.AppCrashedEvent +import pw.binom.rayneovm.shared.AppLaunchedAck +import pw.binom.rayneovm.shared.GlassesStatus +import pw.binom.rayneovm.shared.Pong +import pw.binom.rayneovm.shared.SetAudioMode +import pw.binom.rayneovm.shared.VmMessage +import pw.binom.rayneovm.shared.VmProtocol + +/** + * Контроллер состояния и связи с очками на стороне телефона. + * + * Запускает фоновый цикл авто-реконнекта к очкам через mDNS + WebSocket. + * Хранит текущее состояние связи и обрабатывает входящие/исходящие сообщения. + */ +class VmPhoneController( + @Suppress("unused") private val context: Context, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _state = MutableStateFlow(ConnectionState.Searching) + val state: StateFlow = _state.asStateFlow() + + private val _glassesStatus = MutableStateFlow(null) + val glassesStatus: StateFlow = _glassesStatus.asStateFlow() + + private var currentTransport: StringTransport? = null + private val audioManager by lazy { AudioPlaybackManager(context) } + + fun start() { + if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return + scope.launch { reconnectLoop() } + } + + fun stop() { + currentTransport?.close() + currentTransport = null + audioManager.stop() + scope.coroutineContext[Job]?.cancelChildren() + _state.value = ConnectionState.Disconnected + } + + /** + * Цикл поиска очков через mDNS + подключения. + * + * При разрыве — exponential backoff. + */ + private suspend fun reconnectLoop() { + var attempt = 0 + val backoffMs = listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 30_000L) + while (true) { + try { + _state.value = ConnectionState.Searching + val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp") + // mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с. + // Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT). + val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080) + _state.value = ConnectionState.Connecting(service.host) + val url = "ws://${service.host}:${service.port}/vm" + val transport = Transports.wifi(url, autoReconnect = false) + currentTransport = transport + _state.value = ConnectionState.Connected(service.host) + attempt = 0 + transport.connect( + onMessage = { json -> + val msg = try { + VmProtocol.decodeAny(json) + } catch (_: Throwable) { return@connect } + handleIncoming(msg) + }, + onConnected = { /* уже выставлено в Connecting->Connected */ }, + onDisconnected = { + currentTransport = null + _state.value = ConnectionState.Disconnected + }, + ) + } catch (t: Throwable) { + currentTransport = null + _state.value = ConnectionState.Disconnected + } + val delay = backoffMs[attempt.coerceAtMost(backoffMs.lastIndex)] + attempt++ + kotlinx.coroutines.delay(delay) + } + } + + private fun handleIncoming(msg: VmMessage) { + when (msg) { + is GlassesStatus -> { + _glassesStatus.value = GlassesStatusInfo( + batteryPercent = msg.batteryPercent, + wifiSsid = msg.wifiSsid, + currentApp = msg.currentApp, + running = msg.running, + ) + } + is AppCrashedEvent -> { + _appCrashed.value = msg.packageName + } + is AppLaunchedAck -> { + _appLaunched.value = msg + } + is SetAudioMode -> { + // Аудио режим — отдельный поток, не контролируется этим контроллером напрямую. + // AudioCaptureService на очках стартует/останавливается. + // На телефоне режим управляется UI (toggle на главном экране). + } + is Pong -> { /* keepalive */ } + else -> { /* другие: событийные, форвардим */ } + } + } + + private val _appCrashed = MutableStateFlow(null) + val appCrashed: StateFlow = _appCrashed.asStateFlow() + + private val _appLaunched = MutableStateFlow(null) + val appLaunched: StateFlow = _appLaunched.asStateFlow() + + fun clearAppCrashed() { _appCrashed.value = null } + fun clearAppLaunched() { _appLaunched.value = null } + + /** Отправляет сообщение очкам. */ + suspend fun send(msg: VmMessage) { + val t = currentTransport ?: return + try { + t.send(VmProtocol.encode(msg)) + } catch (t: Throwable) { + // ignore — connection state управляется через reconnect loop + } + } + + /** + * Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE. + * Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager. + */ + suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) { + send(SetAudioMode(mode)) + when (mode) { + pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop() + pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> { + val host = (currentTransport as? pw.binom.mercury.transport.WifiTransport)?.let { null } + ?: (_state.value as? ConnectionState.Connected)?.host + if (host != null) audioManager.start(host) + } + } + } + + companion object { + val Local = staticCompositionLocalOf { + error("VmPhoneController not provided") + } + + fun fromContext(context: Context): VmPhoneController = + (context.applicationContext as? ControllerProvider)?.provideController() + ?: error("Application must implement ControllerProvider") + } +} + +interface ControllerProvider { + fun provideController(): VmPhoneController +} + +sealed interface ConnectionState { + data object Searching : ConnectionState + data class Connecting(val host: String) : ConnectionState + data class Connected(val host: String) : ConnectionState + data object Disconnected : ConnectionState +} + +data class GlassesStatusInfo( + val batteryPercent: Int?, + val wifiSsid: String?, + val currentApp: String?, + val running: Boolean, +) diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/AppsListScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/AppsListScreen.kt new file mode 100644 index 0000000..ce311f6 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/AppsListScreen.kt @@ -0,0 +1,105 @@ +package pw.binom.rayneovm.phone.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import pw.binom.rayneovm.phone.protocol.VmPhoneController +import pw.binom.rayneovm.shared.LaunchAppCommand + +/** + * Список приложений, которые можно запустить на очках через VirtualDisplay. + * + * Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt. + */ +@Composable +fun AppsListScreen( + controller: VmPhoneController, + onAppClick: (String) -> Unit = {}, +) { + val glasses by controller.glassesStatus.collectAsState() + val isRunning = glasses?.running == true + val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() + + val apps = listOf( + AppEntry("free.zona", "Zona", "Кинотеатр Zona"), + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Приложения", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Тапните, чтобы запустить на очках", + color = Color(0xFFB0B0B0), + fontSize = 14.sp, + ) + + apps.forEach { app -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + coroutineScope.launch { + controller.send(LaunchAppCommand(packageName = app.packageName)) + } + onAppClick(app.packageName) + }, + colors = androidx.compose.material3.CardDefaults.cardColors( + containerColor = Color(0xFF1A1A1A), + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = app.name, + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = app.description, + color = Color(0xFFB0B0B0), + fontSize = 12.sp, + ) + if (isRunning && glasses?.currentApp == app.packageName) { + Text( + text = "● запущено", + color = Color(0xFF4CAF50), + fontSize = 12.sp, + ) + } + } + } + } + } +} + +private data class AppEntry( + val packageName: String, + val name: String, + val description: String, +) diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt new file mode 100644 index 0000000..3b86eb0 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt @@ -0,0 +1,263 @@ +package pw.binom.rayneovm.phone.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import pw.binom.mercury.DiscoveredGlasses +import pw.binom.rayneovm.phone.mercury.MercuryController +import pw.binom.rayneovm.phone.mercury.MercuryState +import pw.binom.rayneovm.phone.protocol.ConnectionState +import pw.binom.rayneovm.phone.protocol.VmPhoneController + +/** + * Экран «Очки»: два независимых канала связи + запуск нашего приложения. + * + * - **Канал 1 (WebSocket)** — наше приложение на очках подключается к нам по WiFi/mDNS. + * Это основной control-канал (тач, клавиши, команды). + * - **Канал 2 (Mercury BLE)** — мы прикидываемся штатным приложением RayNeo Mercury + * и общаемся с очками по их закрытому BLE-протоколу. Это bootstrap-канал: + * нужен, чтобы запустить наше приложение через `appMarketLaunch(pkg)`, когда + * очки ещё не подключены к нам по WebSocket. + * + * Кнопка «Запустить наше приложение на очках» работает только когда жив Канал 2. + */ +@Composable +fun GlassesStatusScreen( + controller: VmPhoneController, + mercuryController: MercuryController, + onRequestMercuryPermissions: () -> Unit = {}, +) { + val connection by controller.state.collectAsState() + val glasses by controller.glassesStatus.collectAsState() + val mercury by mercuryController.state.collectAsState() + val scope = rememberCoroutineScope() + + val mercuryConnected = mercury is MercuryState.Connected + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Очки", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + + Channel1WebSocketSection(connection = connection, glasses = glasses) + + HorizontalDivider(color = Color(0xFF2A2A2A)) + + Channel2MercurySection( + mercury = mercury, + onRequestPermissions = onRequestMercuryPermissions, + onScan = { mercuryController.scan() }, + onConnect = { mercuryController.connect(it) }, + onDisconnect = { mercuryController.disconnect() }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LaunchGlassesAppButton( + enabled = mercuryConnected, + onClick = { + scope.launch { + mercuryController.launchGlassesApp(GLASSES_PACKAGE) + } + }, + ) + } +} + +private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses" + +@Composable +private fun Channel1WebSocketSection( + connection: ConnectionState, + glasses: pw.binom.rayneovm.phone.protocol.GlassesStatusInfo?, +) { + SectionCard(title = "Канал 1 — наше приложение (WebSocket)") { + StatusRow("Связь", connection.label) + when (connection) { + is ConnectionState.Connected -> glasses?.let { g -> + StatusRow("Аккумулятор", g.batteryPercent?.let { "$it%" } ?: "—") + StatusRow("WiFi", g.wifiSsid ?: "—") + StatusRow("Запущено", g.currentApp ?: "—") + StatusRow("Активно", if (g.running) "да" else "нет") + } ?: run { + Hint("Ожидаем статус от очков…") + } + else -> Hint( + when (connection) { + is ConnectionState.Searching -> "Ищем очки в сети…" + is ConnectionState.Connecting -> "Подключаемся к ${connection.host}…" + is ConnectionState.Disconnected -> + "Нет связи. Проверьте, что очки и телефон в одной WiFi-сети." + } + ) + } + } +} + +@Composable +private fun Channel2MercurySection( + mercury: MercuryState, + onRequestPermissions: () -> Unit, + onScan: () -> Unit, + onConnect: (DiscoveredGlasses) -> Unit, + onDisconnect: () -> Unit, +) { + SectionCard(title = "Канал 2 — очки (Mercury BLE)") { + when (mercury) { + is MercuryState.PermissionsMissing -> { + Hint("Нужны разрешения Bluetooth (BLUETOOTH_SCAN, BLUETOOTH_CONNECT).") + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onRequestPermissions) { Text("Запросить разрешения") } + } + is MercuryState.BluetoothOff -> { + Hint("Bluetooth выключен. Включите его в системных настройках.") + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onScan) { Text("Проверить ещё раз") } + } + is MercuryState.Disconnected -> { + StatusRow("Связь", "нет связи") + mercury.message?.let { Hint(it) } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onScan) { Text("Сканировать") } + } + is MercuryState.Scanning -> { + StatusRow("Связь", "поиск… (найдено ${mercury.found.size})") + Spacer(modifier = Modifier.height(8.dp)) + if (mercury.found.isNotEmpty()) { + Text("Найденные устройства:", color = Color(0xFFB0B0B0), fontSize = 12.sp) + mercury.found.forEach { g -> + DeviceRow(g, onClick = { onConnect(g) }) + } + } else { + Hint("Идёт скан, подождите…") + } + } + is MercuryState.Connecting -> { + StatusRow("Связь", "сопрягаем ${mercury.device.name ?: mercury.device.address}…") + Hint("Если очки запрашивают PIN — подтвердите на них.") + } + is MercuryState.Connected -> { + StatusRow("Связь", "подключено (${mercury.device.name ?: mercury.device.address})") + StatusRow("Адрес", mercury.device.address) + StatusRow("Аккумулятор", mercury.batteryPercent?.let { "$it%" } ?: "—") + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onDisconnect) { Text("Отключить") } + } + } + } +} + +@Composable +private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF1565C0), + disabledContainerColor = Color(0xFF2A2A2A), + ), + ) { + Text(if (enabled) "Запустить наше приложение на очках" else "Запустить наше приложение на очках (нужен Канал 2)") + } +} + +@Composable +private fun SectionCard(title: String, content: @Composable () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFF141414)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + color = Color(0xFF8AB4F8), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + content() + } + } +} + +@Composable +private fun DeviceRow(glasses: DiscoveredGlasses, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(glasses.name ?: "—", color = Color.White, fontSize = 14.sp) + Text(glasses.address, color = Color(0xFFB0B0B0), fontSize = 11.sp) + } + Button(onClick = onClick) { Text("Подключить") } + } +} + +@Composable +private fun StatusRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, color = Color(0xFFB0B0B0), fontSize = 14.sp) + Text(text = value, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun Hint(text: String) { + Box(modifier = Modifier.padding(top = 4.dp)) { + Text(text = text, color = Color(0xFFB0B0B0), fontSize = 13.sp) + } +} + +private val ConnectionState.label: String + get() = when (this) { + is ConnectionState.Searching -> "поиск…" + is ConnectionState.Connecting -> "подключение…" + is ConnectionState.Connected -> "подключено ($host)" + is ConnectionState.Disconnected -> "нет связи" + } \ No newline at end of file diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt new file mode 100644 index 0000000..a0c4f58 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt @@ -0,0 +1,75 @@ +package pw.binom.rayneovm.phone.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import pw.binom.rayneovm.phone.mercury.MercuryController +import pw.binom.rayneovm.phone.protocol.VmPhoneController + +/** + * Главный экран приложения: NavigationBar с двумя табами. + * + * - «Очки» — статус связи и состояние GlassesApp + * - «Приложения» — список приложений для запуска + */ +@Composable +fun HomeScreen( + controller: VmPhoneController, + mercuryController: MercuryController, + onRequestMercuryPermissions: () -> Unit = {}, +) { + var selectedTab by remember { mutableStateOf(Tab.GLASSES) } + + MaterialTheme(colorScheme = darkColorScheme()) { + Scaffold( + modifier = Modifier.fillMaxSize().background(Color.Black), + containerColor = Color.Black, + bottomBar = { + NavigationBar(containerColor = Color(0xFF101010)) { + Tab.entries.forEach { tab -> + NavigationBarItem( + selected = tab == selectedTab, + onClick = { selectedTab = tab }, + icon = { Icon(tab.icon, contentDescription = tab.label) }, + label = { Text(tab.label) }, + ) + } + } + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when (selectedTab) { + Tab.GLASSES -> GlassesStatusScreen( + controller = controller, + mercuryController = mercuryController, + onRequestMercuryPermissions = onRequestMercuryPermissions, + ) + Tab.APPS -> AppsListScreen(controller = controller) + } + } + } + } +} + +private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) { + GLASSES("Очки", Icons.Default.Settings), + APPS("Приложения", Icons.Default.Apps), +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/TouchpadScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/TouchpadScreen.kt new file mode 100644 index 0000000..451ed42 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/TouchpadScreen.kt @@ -0,0 +1,107 @@ +package pw.binom.rayneovm.phone.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import pw.binom.rayneovm.phone.protocol.VmPhoneController +import pw.binom.rayneovm.shared.TouchAction +import pw.binom.rayneovm.shared.TouchEvent +import pw.binom.rayneovm.shared.TouchType +import kotlin.math.roundToInt + +/** + * Виртуальный тачпад: тапы/перетаскивания → [TouchEvent] на очки. + * + * Координаты внутри тачпада нормализуются в [0, targetWidth-1] × [0, targetHeight-1]. + * Точка касания визуализируется кружком. + */ +@Composable +fun TouchpadScreen( + controller: VmPhoneController, + targetWidth: Int = 640, + targetHeight: Int = 480, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var touchPoint by remember { mutableStateOf(null) } + + Box( + modifier = modifier + .background(Color(0xFF101010)) + .pointerInput(targetWidth, targetHeight) { + detectTapGestures( + onPress = { offset -> + val x = (offset.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1) + val y = (offset.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1) + touchPoint = offset + scope.launch { + controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.DOWN, x.toFloat(), y.toFloat())))) + try { tryAwaitRelease() } finally { + touchPoint = null + } + controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.UP, x.toFloat(), y.toFloat())))) + } + }, + ) + } + .pointerInput(targetWidth, targetHeight) { + detectDragGestures( + onDragStart = { offset -> + val x = (offset.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1) + val y = (offset.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1) + touchPoint = offset + scope.launch { + controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.DOWN, x.toFloat(), y.toFloat())))) + } + }, + onDragEnd = { + touchPoint?.let { last -> + val x = (last.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1) + val y = (last.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1) + scope.launch { + controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.UP, x.toFloat(), y.toFloat())))) + } + } + touchPoint = null + }, + onDragCancel = { touchPoint = null }, + onDrag = { change, _ -> + val x = (change.position.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1) + val y = (change.position.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1) + touchPoint = change.position + scope.launch { + controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.MOVE, x.toFloat(), y.toFloat())))) + } + }, + ) + }, + ) { + touchPoint?.let { pos -> + Box( + modifier = Modifier + .offset { IntOffset((pos.x - 24.dp.toPx()).toInt(), (pos.y - 24.dp.toPx()).toInt()) } + .size(48.dp) + .clip(CircleShape) + .background(Color(0xFF80CBC4).copy(alpha = 0.6f)) + ) + } + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt new file mode 100644 index 0000000..985e63a --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/ZonaProfile.kt @@ -0,0 +1,196 @@ +package pw.binom.rayneovm.phone.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.text.KeyboardOptions +import kotlinx.coroutines.launch +import pw.binom.rayneovm.phone.protocol.VmPhoneController +import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent +import pw.binom.rayneovm.shared.LaunchAppCommand + +/** + * Профиль Zona: тачпад + медиа-кнопки + клавиатура + назад. + */ +@Composable +fun ZonaProfile( + controller: VmPhoneController, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val glasses by controller.glassesStatus.collectAsState() + val appCrashed by controller.appCrashed.collectAsState() + var showKeyboard by remember { mutableStateOf(false) } + + // Если приложение упало — сразу назад (если хочется — можно показать Snackbar) + androidx.compose.runtime.LaunchedEffect(appCrashed) { + if (appCrashed != null) { + // TODO: показать Snackbar и попап обратно в список + controller.clearAppCrashed() + } + } + + Column(modifier = modifier.fillMaxSize().background(Color.Black)) { + // Верхняя панель: назад + название + статус + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { /* TODO: navigateBack */ }) { + Icon(Icons.Default.ArrowBack, contentDescription = "Назад", tint = Color.White) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Zona", + color = Color.White, + fontSize = 18.sp, + ) + Spacer(modifier = Modifier.weight(1f)) + if (glasses?.currentApp == "free.zona") { + Text( + text = if (glasses?.running == true) "●" else "○", + color = if (glasses?.running == true) Color(0xFF4CAF50) else Color(0xFFB0B0B0), + fontSize = 20.sp, + ) + } + } + + // Тачпад (занимает большую часть экрана) + TouchpadScreen( + controller = controller, + modifier = Modifier.weight(1f).fillMaxWidth().padding(8.dp), + ) + + // Нижняя панель: медиа-кнопки + клавиатура + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { + scope.launch { controller.send(VmKeyEvent(keyCode = 89, action = 1)) } // KEYCODE_MEDIA_REWIND + }) { Icon(Icons.Default.SkipPrevious, contentDescription = "Назад", tint = Color.White) } + + IconButton(onClick = { + scope.launch { controller.send(VmKeyEvent(keyCode = 85, action = 1)) } // KEYCODE_MEDIA_PLAY_PAUSE + }) { Icon(Icons.Default.PlayArrow, contentDescription = "Play/Pause", tint = Color.White) } + + IconButton(onClick = { + scope.launch { controller.send(VmKeyEvent(keyCode = 90, action = 1)) } // KEYCODE_MEDIA_FAST_FORWARD + }) { Icon(Icons.Default.SkipNext, contentDescription = "Вперёд", tint = Color.White) } + + IconButton(onClick = { + scope.launch { controller.send(VmKeyEvent(keyCode = 4, action = 1)) } // KEYCODE_BACK + }) { Icon(Icons.Default.ArrowBack, contentDescription = "Назад (в приложении)", tint = Color.White) } + + IconButton(onClick = { showKeyboard = true }) { + Icon(Icons.Default.Keyboard, contentDescription = "Клавиатура", tint = Color.White) + } + } + } + + if (showKeyboard) { + KeyboardDialog( + onDismiss = { showKeyboard = false }, + onSend = { text -> + scope.launch { + text.forEach { ch -> + val keyCode = keyCodeForChar(ch) + if (keyCode != -1) { + controller.send(VmKeyEvent(keyCode = keyCode, action = 1)) + } + } + } + showKeyboard = false + }, + ) + } +} + +@Composable +private fun KeyboardDialog( + onDismiss: () -> Unit, + onSend: (String) -> Unit, +) { + var input by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Ввод текста") }, + text = { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + ) + }, + confirmButton = { + TextButton(onClick = { onSend(input) }) { Text("Отправить") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Отмена") } + }, + ) +} + +/** + * ASCII char → Android keyCode. Простая таблица, без юникода и без спец.клавиш. + * Возвращает -1 для символов, которые нельзя передать через KEYCODE. + */ +private fun keyCodeForChar(ch: Char): Int { + return when (ch) { + in 'a'..'z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'a') + in 'A'..'Z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'A') + in '0'..'9' -> android.view.KeyEvent.KEYCODE_0 + (ch - '0') + ' ' -> android.view.KeyEvent.KEYCODE_SPACE + '\n' -> android.view.KeyEvent.KEYCODE_ENTER + '.' -> android.view.KeyEvent.KEYCODE_PERIOD + ',' -> android.view.KeyEvent.KEYCODE_COMMA + '!' -> android.view.KeyEvent.KEYCODE_1 // shift+1 — отдаём как KEYCODE_1 + shift, но тут упрощённо + '?' -> android.view.KeyEvent.KEYCODE_SLASH + else -> -1 + } +} + +@Composable +private fun Spacer(modifier: Modifier) { + androidx.compose.foundation.layout.Spacer(modifier = modifier) +} diff --git a/app-phone-vm/src/main/res/values/strings.xml b/app-phone-vm/src/main/res/values/strings.xml new file mode 100644 index 0000000..9420c84 --- /dev/null +++ b/app-phone-vm/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + RayNeo VM + diff --git a/app-phone-vm/src/main/res/values/themes.xml b/app-phone-vm/src/main/res/values/themes.xml new file mode 100644 index 0000000..08a933d --- /dev/null +++ b/app-phone-vm/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..545244a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..90e1081 --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,1369 @@ +# ТЗ: rayneo-vm — запуск произвольного приложения на очках RayNeo X3 Pro + +> **Статус документа:** черновик (v0.6). Будет уточняться по ходу разработки. + +--- + +## 1. Цель + +Сделать систему из **двух подмодулей** + **общей либы**, которая позволяет запускать произвольное Android-приложение (например, онлайн-кинотеатр **Zona**) на умных очках **RayNeo X3 Pro** и управлять им с телефона. + +### 1.1. Подмодули + +| Подмодуль | Где работает | Что делает | +|---|---|---| +| **`app-glasses-vm`** (GlassesApp) | на очках RayNeo X3 Pro | Создаёт `VirtualDisplay` 640×480, запускает на нём «гостевое» приложение, захватывает кадры и рисует их в обе половины физического дисплея очков (1280×480). Принимает команды управления от телефона. | +| **`app-phone-vm`** (PhoneApp) | на обычном Android-телефоне | UI с тачпадом и кнопкой клавиатуры. Передаёт события тачпада и клавиатуры на очки. | + +### 1.2. Общая либа `rayneo-mercury` + +Переиспользуемые компоненты живут как **новые модули** внутри существующей либы `/home/subochev/common-projects/WORK/rayneo-mercury`: + +| Модуль | Что внутри | +|---|---| +| `:transport` | Канал связи телефон ↔ очки: WebSocket по WiFi, Bluetooth fallback, mDNS-дискавери, общий протокол сообщений. Переносим из view-mate/lib-core (адаптируем namespace и зависимости). | +| `:binocular-renderer` | Обобщённый бинокулярный EGL-рендерер. Принимает «источник кадров» (интерфейс) и рисует его в обе половины экрана очков. Источник может быть видеоплеер (view-mate) или `SurfaceTexture` от VirtualDisplay (rayneo-vm). | + +view-mate ничего не правим — другой агент мигрирует его на новые модули позже. + +--- + +## 2. Термины + +| Термин | Значение | +|---|---| +| **Glasses** | RayNeo X3 Pro (модель ARGF20, Android 12, API 32). | +| **Phone** | Обычный Android-телефон (компаньон). | +| **GlassesApp** | Подмодуль `app-glasses-vm` — приложение, работающее на очках. | +| **PhoneApp** | Подмодуль `app-phone-vm` — приложение, работающее на телефоне. | +| **VM-сервис** | Shizuku UserService внутри GlassesApp — в нём делаем всю работу с системными API (создание VirtualDisplay, запуск Activity). | +| **Виртуальный экран (VirtualDisplay)** | Программно созданный Android-дисплей 640×480 landscape, на котором работает «гостевое» приложение. | +| **Гостевое приложение** | `free.zona` — онлайн-кинотеатр, который мы запускаем на VirtualDisplay. | +| **Бинокулярный вывод** | Отображение одной и той же картинки дважды: левая половина → левый глаз, правая половина → правый глаз. | +| **Канал связи** | WebSocket-соединение между GlassesApp и PhoneApp (WiFi) с Bluetooth-фоллбеком. | +| **binocular-renderer** | Общий модуль в rayneo-mercury, умеет рисовать произвольный источник кадров в обе половины экрана очков. | + +--- + +## 3. Архитектура + +``` + Телефон (PhoneApp) Очки (GlassesApp) + ┌─────────────────────┐ ┌──────────────────────────┐ + │ │ WebSocket (WiFi) │ MainActivity │ + │ Тачпад (тапы/ │◄──────────────────────►│ (binocular-renderer) │ + │ свайпы/жесты) │ BT (fallback) │ │ + │ │ │ ┌────────────────────┐ │ + │ Кнопка «клавиатура»│ │ │ Shizuku UserService│ │ + │ → системная │ │ │ │ │ + │ клавиатура │ │ │ • IDisplayManager │ │ + │ │ │ │ .createVirtual │ │ + │ Статус-индикатор: │ │ │ Display(...) │ │ + │ связь/батарея/ │ │ │ • IActivityTaskMgr │ │ + │ состояние │ │ │ .startActivity │ │ + │ │ │ │ AsUser(...) │ │ + └─────────────────────┘ │ └────────┬───────────┘ │ + │ │ │ + │ ▼ │ + │ VirtualDisplay 640×480 │ + │ ┌────────────────────┐ │ + │ │ free.zona │ │ + │ │ (кинотеатр) │ │ + │ └────────────────────┘ │ + │ │ │ + │ Surface кадров │ + │ ▼ │ + │ binocular-renderer │ + │ (текстура → 2 копии │ + │ в 1280×480) │ + └──────────────────────────┘ + │ + Оптика очков: делит + 1280×480 на 2 × 640×480 + (L + R) +``` + +### 3.1. Поток данных + +1. Пользователь на телефоне тапает по тачпаду → PhoneApp формирует событие. +2. PhoneApp отправляет событие по WebSocket-каналу в GlassesApp. +3. GlassesApp пересылает событие в VM-сервис через `InputManager.injectInputEvent(...)` — событие попадает в активное приложение (`free.zona`) на VirtualDisplay. +4. `free.zona` рисует кадр в Surface виртуального дисплея. +5. Кадр через `SurfaceTexture` → GL-текстуру → binocular-renderer → 2 копии в `1280×480` физический дисплей очков. +6. Оптика очков раздаёт левую половину на левый глаз, правую — на правый. + +--- + +## 4. Функциональные требования + +### 4.1. Подмодуль `app-glasses-vm` (GlassesApp) + +- Устанавливается на очки (Android 12, API 32, arm64-v8a). +- Запускается в landscape на `displayId=0` (1280×480). +- **UI на Jetpack Compose** (как PhoneApp — единый стек). GL-рендер для видео встроен через `AndroidView { SurfaceView(...) }`. +- Использует binocular-renderer из `:binocular-renderer` для отрисовки кадров VirtualDisplay. +- Поднимает VM-сервис (Shizuku UserService). +- Запускает канал связи (WebSocket-сервер на очках, ждёт подключения от PhoneApp). +- Получает от PhoneApp события управления и отправляет их через `InputManager.injectInputEvent` в активное приложение на VirtualDisplay. + +**Структура Compose-дерева:** + +```kotlin +@Composable +fun GlassesScreen(controller: VmGlassesController, modifier: Modifier = Modifier) { + val state by controller.screenState.collectAsStateWithLifecycle() + Box(modifier = modifier.fillMaxSize().background(Color.Black)) { + // Слой 1: GL рендер кадров VirtualDisplay (для состояния Running) + if (state is ScreenState.Running) { + BinocularRendererView( + frameSource = controller.frameSource, + modifier = Modifier.fillMaxSize(), + ) + } + // Слой 2: Compose overlay (поверх GL или вместо него) + when (state) { + is ScreenState.Starting -> LoadingOverlay("Загрузка...") + is ScreenState.ShizukuMissing -> MessageOverlay("Shizuku не активен. Запустите на телефоне и перезапустите приложение.") + is ScreenState.NoAppSelected -> MessageOverlay("Откройте приложение на телефоне") + is ScreenState.Running -> if (state.overlay == OverlayKind.NoConnection) NoConnectionOverlay() + } + } +} +``` + +**`BinocularRendererView`** — `@Composable` обёртка из rayneo-mercury `:binocular-renderer`: + +```kotlin +@Composable +fun BinocularRendererView( + frameSource: FrameSource, + modifier: Modifier = Modifier, +) { + val renderer = remember(frameSource) { BinocularEglRenderer(frameSource) } + AndroidView( + factory = { ctx -> + SurfaceView(ctx).also { sv -> + sv.holder.addCallback(renderer.createCallback()) + } + }, + modifier = modifier, + ) +} +``` + +Внутри `BinocularEglRenderer` — EGL-стек: создание GL-контекста, шейдеры, обновление текстуры из `SurfaceTexture` (подключенного к Surface виртуального дисплея), отрисовка **в две половины экрана 1280×480** одним drawcall. + +### 4.2. Binocular-renderer (общий модуль в rayneo-mercury) + +- **Публичный API:** `@Composable fun BinocularRendererView(frameSource: FrameSource, modifier: Modifier = Modifier)` — встраивается в любое Compose-дерево через `AndroidView { SurfaceView }`. +- **Внутри:** `BinocularEglRenderer` — класс с EGL-контекстом, шейдерами, текстурой. +- Делит экран **строго по вертикали пополам** одним drawcall: + - Левая половина (x ∈ [0, w/2]) → левый глаз. + - Правая половина (x ∈ [w/2, w]) → правый глаз. +- В обеих половинах рисует **одно и то же изображение** (текущий кадр из источника), без искажений и цветокоррекции. +- Ориентация: фиксированный landscape. +- **Источник кадров** — абстракция (интерфейс `FrameSource`): + - `VideoFrameSource` — для view-mate (берёт кадры из ExoPlayer). + - `SurfaceTextureFrameSource` — для rayneo-vm (берёт кадры из `SurfaceTexture`, подключённого к VirtualDisplay). + +> Это и есть тот самый «один модуль на двоих»: один рендерер, разные источники. GL-стек одинаковый, разница только в том, откуда берутся кадры. + +### 4.3. VirtualDisplay 640×480 + +- Создаётся в **VM-сервисе** через `IDisplayManager.createVirtualDisplay(...)` **без** `VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY`. +- Параметры: + - Размер: 640×480, фиксированно. + - Ориентация: landscape. + - Density: 160 dpi (комфортно для `free.zona`). +- Display виден всей системе, в т.ч. `free.zona` может на нём запуститься. +- Жизненный цикл: создаётся в VM-сервисе по команде из GlassesApp, освобождается при остановке. + +### 4.4. Запуск гостевого приложения + +- В VM-сервисе через `IActivityTaskManager.startActivityAsUser(...)`: + - `ActivityOptions.setLaunchDisplayId(virtualDisplayId)` — целевой display. + - Компонент `free.zona/ru.zona.app.android.MainActivity`. + - `UserHandle.SYSTEM` или `UserHandle.CURRENT` — уточнить на этапе разработки. + +### 4.5. Захват кадров с VirtualDisplay + +- `IDisplayManager.createVirtualDisplay(...)` возвращает **Surface**, в который система рисует кадры `free.zona`. +- Этот Surface пробрасываем из VM-сервиса в GlassesApp (по AIDL-биндеру, как `Parcelable`-обёртку или через `SurfaceTexture`). +- В GlassesApp подключаем к Surface `SurfaceTexture` и через него обновляем GL-текстуру в binocular-renderer. + +### 4.6. Подмодуль `app-phone-vm` (PhoneApp) + +**Общая структура UI** (Compose Navigation): + +``` +HomeScreen +├── таб "Очки" → GlassesStatusScreen (батарея, память, текущее приложение — как в view-mate) +└── таб "Приложения" → AppsListScreen (список AppProfile'ов) + └── тап на приложение → AppControlScreen(profile = ...) (специфичный UI) +``` + +#### 4.6.1. Профили приложений (`AppProfile`) + +У каждого приложения — свой UI управления (тачпад + специфичные кнопки). Общий механизм — интерфейс `AppProfile` в `:app-phone-vm`: + +```kotlin +interface AppProfile { + val packageName: String // "free.zona" + val displayName: String // "Zona" + @Composable + fun Controls( + protocol: VmProtocol, + modifier: Modifier = Modifier, + ) +} + +object ZonaProfile : AppProfile { + override val packageName = "free.zona" + override val displayName = "Zona" + @Composable + override fun Controls(protocol: VmProtocol, modifier: Modifier) { + // тачпад + медиа-кнопки + клавиатура + назад — см. ниже + } +} + +// Будущее: +// object JellyfinProfile : AppProfile { ... } + +val profiles: List = listOf(ZonaProfile /*, JellyfinProfile */) +``` + +#### 4.6.2. ZonaProfile.Controls + +```kotlin +@Composable +override fun Controls(protocol: VmProtocol, modifier: Modifier) { + var showKeyboard by remember { mutableStateOf(false) } + Column(modifier) { + TouchpadView( + onTouch = { actions -> protocol.send(TouchEvent(actions)) }, + modifier = Modifier.weight(1f), // тачпад во всё доступное место + ) + Row(verticalAlignment = Alignment.CenterVertically) { + MediaButton("⏪") { protocol.send(KeyEvent(KeyEvent.KEYCODE_MEDIA_REWIND, DOWN)) } + MediaButton("⏯") { protocol.send(KeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, DOWN)) } + MediaButton("⏩") { protocol.send(KeyEvent(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, DOWN)) } + MediaButton("⌨") { showKeyboard = true } + MediaButton("←") { protocol.send(KeyEvent(KeyEvent.KEYCODE_BACK, DOWN)) } + } + } + if (showKeyboard) KeyboardDialog(onDismiss = { showKeyboard = false }) { text -> + // Каждый символ → KeyEvent с KEYCODE_* в Zona + for (ch in text) protocol.send(KeyEvent(KeyEvent.keyCodeForChar(ch), DOWN)) + } +} +``` + +**Кнопки:** +- ⏪ `KEYCODE_MEDIA_REWIND` — перемотка назад +- ⏯ `KEYCODE_MEDIA_PLAY_PAUSE` — плей/пауза +- ⏩ `KEYCODE_MEDIA_FAST_FORWARD` — перемотка вперёд +- ⌨ — открывает диалог клавиатуры +- ← `KEYCODE_BACK` — назад + +Все команды отправляются как обычные `KeyEvent` (DTO уже умеет любые `keyCode`). Прибывают на очки → `injectKeyEvent` через VM-сервис в активное приложение. + +#### 4.6.3. Клавиатура — диалог с полем ввода + +Всплывающее окно с `OutlinedTextField` и кнопкой «Отправить»: + +```kotlin +@Composable +fun KeyboardDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) { + var text by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Ввод текста") }, + text = { OutlinedTextField(value = text, onValueChange = { text = it }, singleLine = false) }, + confirmButton = { Button(onClick = { onSubmit(text); onDismiss() }) { Text("Отправить") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} +``` + +При «Отправить» каждый символ строки → `KeyEvent` с `keyCode = KeyEvent.keyCodeForChar(ch)` → на очки → `injectKeyEvent`. В Zona текст появляется посимвольно (так работает любая виртуальная клавиатура). + +> **Почему не системная клавиатура:** системная требует управления фокусом Compose + серьёзные проблемы с `WindowInsets`/`ImeAction` в Compose. Поле ввода с кнопкой «Отправить» проще, надёжнее, и UX в данном случае даже удобнее (юзер видит весь ввод перед отправкой). + +#### 4.6.4. Тачпад + +`TouchpadView` — Compose-обёртка над `Box.onTouchEvent`. Маппинг координат: тачпад во весь экран → координаты 0..640 × 0..480 (разрешение VirtualDisplay). Отправляет серии `TouchEvent(actions=[down, move, move, up])`. + +#### 4.6.5. Запуск приложения + +**Запуск GlassesApp на очках** (через Mercury-канал): + +1. PhoneApp обнаруживает, что WebSocket-канал не поднялся (mDNS discovery не нашёл наш сервис). +2. На `GlassesStatusScreen` показывается кнопка «▶ Запустить наше приложение на очках». +3. По нажатию — `MercuryBridge.launchApp("pw.binom.rayneovm.glasses")` через BLE-протокол RayNeo. +4. Очки сами открывают наше приложение (`Intent.PACKAGE_LAUNCH` по пакету). +5. GlassesApp стартует → поднимает WebSocket-сервер → mDNS publish → PhoneApp находит → подключается. + +**Запуск гостевого приложения (Zona)** (через WebSocket-канал): + +1. При тапе на профиль в `AppsListScreen` PhoneApp шлёт `LaunchAppCommand(packageName="free.zona")` через `VmProtocol` (WebSocket). +2. GlassesApp получает → VM-сервис запускает/фокусирует Activity (`IActivityTaskManager.startActivityAsUser` + `setLaunchDisplayId`). +3. Если приложение уже запущено — просто фокусируем. +4. Если не запущено — стартуем. +5. PhoneApp переходит на `AppControlScreen(profile)`. + +#### 4.6.6. Статус очков (таб «Очки») + +**Два независимых канала связи с очками:** + +| Канал | Технология | Назначение | +|---|---|---| +| **Mercury-канал** (служебный) | BLE, фирменный протокол RayNeo Mercury (`pw.binom.mercury:library`) | Спаривание, статус очков (батарея/яркость/WiFi), **запуск нашего GlassesApp по пакету** через `appMarketLaunch` | +| **WebSocket-канал** (основной) | WiFi, наш `:transport` модуль | Управление запущенным GlassesApp: тачи, клавиши, команды Zona | + +Mercury-канал доступен всегда (когда очки спарены), даже если наше GlassesApp ещё не запущено. Используем его для: +1. Проверки, что очки вообще доступны. +2. Получения батареи/статуса. +3. **Запуска GlassesApp одной кнопкой** (`MercuryBridge.launchApp("pw.binom.rayneovm.glasses")` — очки сами открывают наш пакет). +4. Гашения/включения экрана очков, управления WiFi очков, запрета засыпания (heartbeat). + +**Экран `GlassesStatusScreen`:** + +``` +┌──────────────────────────────────────────┐ +│ Очки │ +│ │ +│ Bluetooth-соединение: ✓ подключено │ +│ Батарея очков: 87% │ +│ Яркость экрана: 60% │ +│ WiFi очков: ✓ SSID "HomeNet" │ +│ │ +│ Наше приложение на очках: ✗ не запущено │ ← определяем через mDNS discovery +│ │ +│ [▶ Запустить наше приложение на очках] │ ← кнопка видна если не запущено +│ │ +│ --- WebSocket-канал --- │ +│ Связь с приложением: ✓ / ✗ │ ← через mDNS / WebSocket status +│ Текущее приложение: Zona │ ← из GlassesStatus.currentApp +└──────────────────────────────────────────┘ +``` + +**ViewModel `GlassesStatusViewModel`** держит: +- `mercuryConnected: StateFlow` +- `glassesBattery: StateFlow` +- `glassesBrightness: StateFlow` +- `glassesWifi: StateFlow` +- `appLaunchedOnGlasses: StateFlow` — обновляется через mDNS-сканирование каждые 5с +- `wsConnected: StateFlow` — состояние WebSocket-канала + +#### 4.6.7. UI design choices (зафиксировано) + +| Параметр | Решение | +|---|---| +| UI-фреймворк | **Jetpack Compose + Material 3** | +| Тема | **Только тёмная** (минимум кода, типично для кино-приложения) | +| Навигация | **Bottom navigation bar** (Material 3 `NavigationBar`), табы: «Очки» / «Приложения» | +| Тачпад визуал | Серый/тёмный фон + точка в месте касания (визуальная обратная связь) | +| Архитектура UI | **MVVM**: Composable + ViewModel + StateFlow (через `collectAsStateWithLifecycle`) | + +#### 4.6.8. Shizuku UX + +При запуске GlassesApp проверяем Shizuku: +- **Если Shizuku активен** — работаем штатно. +- **Если Shizuku не активен** — полноэкранный блок с объяснением: + - Что такое Shizuku, зачем он нужен. + - Как установить (`market://...` или ссылка на сайт rikka.app). + - Как активировать в ADB-режиме. + - Кнопка «Открыть настройки Shizuku» (intent на `moe.shizuku.manager`). +- При появлении Shizuku (через listener `Shizuku.addBinderReceivedListener`) — автоматически убираем блок. + +#### 4.6.9. Потеря связи GlassesApp ↔ PhoneApp + +- WebSocket-обрыв → авто-реконнект в фоне с экспоненциальным backoff: 1с → 2с → 4с → 8с → 16с → 30с (макс). +- Параллельно — Snackbar «Связь потеряна, переподключение...». +- При восстановлении — Snackbar «Связь восстановлена». +- При восстановлении — `VmGlassesController` синхронизирует состояние (какое приложение запущено, режим звука и т.п.). + +#### 4.6.10. Архитектура UI (MVVM) + +Каждый экран = Composable + ViewModel. ViewModel держит `StateFlow`, Composable подписывается через `collectAsStateWithLifecycle()`. + +``` +PhoneApp/ +├── ui/ +│ ├── home/ +│ │ ├── HomeScreen.kt # корневой экран с NavigationBar +│ │ ├── HomeViewModel.kt +│ │ └── HomeState.kt +│ ├── glasses_status/ +│ │ ├── GlassesStatusScreen.kt +│ │ ├── GlassesStatusViewModel.kt +│ │ └── GlassesStatusState.kt +│ ├── apps_list/ +│ │ ├── AppsListScreen.kt +│ │ ├── AppsListViewModel.kt +│ │ └── AppsListState.kt +│ ├── app_control/ +│ │ ├── AppControlScreen.kt # обёртка, выбирает AppProfile.Controls +│ │ ├── AppControlViewModel.kt +│ │ └── AppControlState.kt +│ ├── profiles/ +│ │ ├── AppProfile.kt # интерфейс +│ │ └── ZonaProfile.kt +│ ├── components/ +│ │ ├── TouchpadView.kt +│ │ ├── MediaButton.kt +│ │ ├── KeyboardDialog.kt +│ │ └── ConnectionStatus.kt +│ └── theme/ +│ ├── Theme.kt # Material 3, только dark +│ ├── Color.kt +│ └── Type.kt +└── ... +``` + +#### 4.6.11. Обработка краша гостевого приложения + +**Что происходит, когда гостевое приложение (например, `free.zona`) падает:** + +| Что упало | Что остаётся | Что пропадает | +|---|---|---| +| Activity (unhandled exception) | VirtualDisplay, Surface, процесс | Activity мертва, нужно перезапустить | +| Процесс приложения (OOM killer, SIGKILL) | VirtualDisplay, Surface (рисует чёрный/последний кадр) | Процесс мёртв | +| Наш VM-сервис | — | VirtualDisplay + Surface исчезают | +| Наш GlassesApp | VM-сервис живёт в отдельном процессе | WebSocket-сервер падает | + +**Главное:** `VirtualDisplay` привязан к нашему VM-сервису (нашему процессу), а не к процессу гостя. VirtualDisplay остаётся живой после падения гостя — мы просто перезапускаем Activity на ней. + +**Детектирование:** в VM-сервисе подписываемся на `IActivityTaskManager.registerTaskStackListener(...)` (доступно через Shizuku). На каждый `onTaskDestroyed(taskInfo)` проверяем `taskInfo.baseActivity.packageName` — если это гостевое приложение, шлём событие в PhoneApp. + +**Без автоперезапуска** (по решению пользователя): + +1. VM-сервис получает `onTaskDestroyed` для пакета `free.zona`. +2. Шлёт `AppCrashedEvent(packageName)` через WebSocket в PhoneApp. +3. `AppControlViewModel` ловит событие: + - `Snackbar` «Zona упала» + - `navController.popBackStack()` → возврат на `AppsListScreen` +4. Пользователь сам решает — тапает на Zona снова или нет. + +**Без лимита рестартов** (раз нет автоперезапуска, лимит не нужен). + +**DTO:** +```kotlin +@Serializable @SerialName("app_crashed") +data class AppCrashedEvent(val packageName: String) : VmMessage +``` + +**Edge cases (не покрываем на этой ночи, отложено):** +- VM-сервис упал → детектим через `DeadObjectException` на клиенте, перезапуск сервиса. +- GlassesApp упал → WebSocket обрыв → уже покрыто авто-реконнектом + Snackbar (раздел 4.6.9). +- Zona падает в цикле при ручном рестарте → пользователь сам разбирается (видит, что падает). + +#### 4.6.12. Состояния экрана очков (GlassesApp рендерит) + +GlassesApp — это не «прозрачный пайплайн», у него есть **свой overlay-режим** для случаев, когда показывать VirtualDisplay не нужно или он недоступен. Реализуется через `BinocularRenderer` с переключением между двумя `FrameSource`'ами: `VirtualDisplayFrameSource` (основной) и `OverlayFrameSource` (для надписей и заглушек). + +**Состояние 1: «Всё работает»** (VirtualDisplay есть, PhoneApp подключен) +- Источник: `VirtualDisplayFrameSource` +- Что видно: живой кадр из Zona (или любого запущенного приложения). + +**Состояние 2: «Нет связи с PhoneApp»** (VirtualDisplay есть, WebSocket-канал оборван >5с) +- Источник: `OverlayFrameSource` поверх `VirtualDisplayFrameSource` +- Что видно: последний кадр VirtualDisplay + полупрозрачная надпись «⚠ Нет связи» по центру. +- Когда связь восстановилась → возвращаемся к состоянию 1. +- Детектирование: WebSocket `onDisconnected` срабатывает, через 5с показываем overlay. При `onConnected` — убираем. + +**Состояние 3: «Приложение не выбрано»** (VirtualDisplay есть, но гостевое приложение не запущено) +- Источник: `OverlayFrameSource` (полностью перекрывает VirtualDisplay) +- Что видно: чёрный фон + текст по центру «Откройте приложение на телефоне». + +**Состояние 4: «Shizuku не активен»** (GlassesApp стартует, Shizuku не разрешает) +- Источник: `OverlayFrameSource` +- Что видно: чёрный фон + текст «Shizuku не активен. Запустите Shizuku на телефоне и перезапустите приложение». (Телефон отдельно показывает инструкцию в своём UI.) + +**Состояние 5: «Стартует»** (GlassesApp запустился, ещё ничего не инициализировано) +- Источник: `OverlayFrameSource` +- Что видно: чёрный фон + логотип/название «RayNeo VM» + текст «Загрузка...». + +**Управление состоянием:** `VmGlassesController` держит `StateFlow`, рендерер подписывается и переключает `FrameSource` при изменении. + +``` +sealed interface ScreenState { + object Starting : ScreenState + object ShizukuMissing : ScreenState + object NoAppSelected : ScreenState + data class Running(val overlay: OverlayKind = OverlayKind.None) : ScreenState +} + +enum class OverlayKind { None, NoConnection } +``` + +### 4.7. Канал связи и протокол + +#### 4.7.1. Разделение ответственности + +> **Транспорт vs протокол — это разные вещи.** +> - **Транспорт** (модуль `:transport` в `rayneo-mercury`) решает **КАК** передавать байты/строки: WebSocket, BT, реконнект, discovery. Не знает про домен. +> - **Протокол** (в коде rayneo-vm) решает **ЧТО** передавать: DTO, формат (JSON / protobuf / что угодно), семантика событий. + +Это позволяет переиспользовать `:transport` где угодно (другие приложения, другие домены), не копируя код. + +#### 4.7.2. Канал связи (из `:transport`) + +- **WiFi (основной):** WebSocket через Ktor. Телефон поднимает `WifiServerTransport`, очки — `WifiTransport`-клиент. Discovery через `MdnsScanner` (+ fallback скан /24, потому что на RayNeo multicast режется). +- **Bluetooth (fallback):** классический Bluetooth SPP / BLE GATT. `BtServerTransport` на телефоне, `BtTransport` на очках. +- **Автоматика:** авто-реконнект при потере связи, индикация статуса. +- Кто инициирует соединение: **телефон → очки**. Телефон публикует себя (mDNS / BT discoverable), очки находят и подключаются. + +#### 4.7.3. Протокол rayneo-vm (НЕ в либе, в коде приложения) + +Свои DTO, своя сериализация — живут в `app-glasses-vm` / `app-phone-vm`: + +```kotlin +// от Phone → Glasses: тап/свайп по тачпаду +@Serializable data class TouchEvent( + val displayId: Int, + val actions: List // серия down/move/up с координатами 0..640, 0..480 +) + +// от Phone → Glasses: клавиша +@Serializable data class KeyEvent( + val keyCode: Int, + val action: KeyAction // DOWN / UP +) + +// от Glasses → Phone: статус (батарея, текущее приложение, состояние) +@Serializable data class GlassesStatus( + val batteryPercent: Int?, + val currentApp: String?, + val connectionOk: Boolean, + val displayId: Int +) +``` + +Формат — JSON (через kotlinx-serialization). Транспорт `:transport` передаёт их как `String` через `StringTransport.send(...)`. + +### 4.8. Управление / инпут в гостевое приложение + +- PhoneApp → GlassesApp → VM-сервис → `InputManager.injectInputEvent(...)` → активное приложение на VirtualDisplay. +- Координаты тачпада на телефоне мапятся в координаты VirtualDisplay (640×480). +- `injectInputEvent` требует `INJECT_EVENTS` permission — даём через Shizuku UserService (см. 8.4.4). +- Опционально — long-press для контекстного меню, swipe для скролла, multi-touch (pinch-to-zoom, если Zona поддерживает). + +### 4.9. Интеграция с rayneo-mercury + +| Наш модуль | Зависимость от rayneo-mercury | +|---|---| +| `app-glasses-vm` | `:binocular-renderer` (для рендера), `:transport` (для WebSocket-сервера, mDNS-сервера), `:shizuku-vd` (для UserService) | +| `app-phone-vm` | `:transport` (для WebSocket-клиента, mDNS-клиента), `:library` (для Mercury BLE — спаривание, статус, **запуск GlassesApp**) | + +Подключение через Maven-координаты `pw.binom.mercury::`, репо — локальный Nexus `http://192.168.76.117/repository/caffeine/`. Креды — в `~/.gradle/gradle.properties` (уже настроено для существующего `rayneo-mercury:library`). + +--- + +## 4.10. Структура приложения rayneo-vm + +### 4.10.1. Gradle-модули + +``` +rayneo-vm/ +├── settings.gradle.kts # include :app-glasses-vm, :app-phone-vm, :shared +├── build.gradle.kts # root +├── gradle.properties +├── docs/SPEC.md # этот документ +│ +├── shared/ # общий код между двумя приложениями (НЕ в rayneo-mercury) +│ ├── build.gradle.kts # плагин kotlinx-serialization, без зависимостей от rayneo-mercury +│ └── src/commonMain/kotlin/pw/binom/rayneovm/shared/ +│ ├── VmMessage.kt # sealed interface VmMessage — базовый тип +│ ├── TouchEvent.kt # @Serializable @SerialName("touch") data class TouchEvent(...) +│ ├── TouchAction.kt # ACTION_DOWN/MOVE/UP + координаты 0..640, 0..480 +│ ├── KeyEvent.kt # @Serializable @SerialName("key") data class KeyEvent(...) +│ ├── KeyAction.kt # DOWN/UP + keyCode +│ └── GlassesStatus.kt # @Serializable @SerialName("status") data class GlassesStatus(...) +│ +├── app-glasses-vm/ # приложение для очков RayNeo X3 Pro +│ ├── build.gradle.kts # зависимости: :shared, rayneo-mercury:binocular-renderer, +│ │ # rayneo-mercury:transport, rayneo-mercury:shizuku-vd, +│ │ # dev.rikka.shizuku:api, RayNeo X3 Pro SDK +│ └── src/main/... +│ +└── app-phone-vm/ # приложение для телефона + ├── build.gradle.kts # зависимости: :shared, rayneo-mercury:transport, Compose, Activity + └── src/main/... +``` + +> **Важно:** `:shared` — это модуль **внутри** rayneo-vm, не в `rayneo-mercury`. DTO не публикуем, не шарим между проектами. Просто общий код двух приложений одного проекта. + +### 4.10.2. Классы в `app-glasses-vm` + +| Класс | Ответственность | +|---|---| +| `VmGlassesApp : Application` | Инициализация зависимостей, регистрация Shizuku permission listener, запуск `VmService` через `startForegroundService` в `onCreate`. | +| `MainActivity : ComponentActivity` | Compose-host. Ставит fullscreen + immersive, рендерит `GlassesScreen(controller)`. Управляет bindService на `VmService`. | +| `VmService : LifecycleService` | **Foreground service** в процессе GlassesApp. Внутри: WiFi-сервер (WebSocket через `:transport`), mDNS-публикация, `VmProtocol`. Service устойчив к lifecycle Activity — сервер и mDNS живут пока Service foreground. | +| `VmGlassesController` | Оркестратор. Держит ссылки на Service, рендер, протокол. Управляет состоянием экрана (`ScreenState` flow). | +| `VirtualDisplayFrameSource : FrameSource` | Реализация интерфейса из `:binocular-renderer`. Принимает `SurfaceTexture` (получен из VM-сервиса), обновляет GL-текстуру для каждого кадра. | +| `OverlayFrameSource : FrameSource` | Рендерит Compose-overlay (текст, статус) вместо живого видео для состояний `Starting`, `ShizukuMissing`, `NoAppSelected`, `NoConnection`. | +| `VmProtocol` | Обёртка над `StringTransport` (из `:transport`). Декодит входящие строки в `VmMessage`, кодит исходящие. | +| `VmShizukuService` | Клиент к Shizuku UserService. Содержит AIDL-биндер, через который дёргает методы сервиса. | +| `VmShizukuUserService : UserService` | Реализация UserService (запускается в отдельном процессе с правами shell). В нём: создание VirtualDisplay, запуск `free.zona`, проброс Surface обратно. | + +**Lifecycle VmService:** +- `onCreate`: создаёт Ktor `CIO` server engine, регистрирует WebSocket endpoint `/ws/rayneo-vm`, запускает mDNS publisher (`_rayneo-vm._tcp.local`). +- `onStartCommand`: `startForeground(NOTIFICATION_ID, notification)` — держит себя foreground. +- `onDestroy`: останавливает сервер, отменяет mDNS. +- Service не пересоздаётся системой (foreground). + +**Архитектурно:** WebSocket и mDNS живут в **нашем процессе** (GlassesApp), а системные вызовы — в **отдельном процессе** (Shizuku UserService). Связь между ними через AIDL-биндер (тот же `VmShizukuService`). Это даёт: GlassesApp можно убить/перезапустить без потери Shizuku-сессии (но WebSocket перезапустится вместе с ним). + +### 4.10.3. Классы в `app-phone-vm` + +| Класс | Ответственность | +|---|---| +| `VmPhoneApp : Application` | Инициализация зависимостей, инициализация MercuryBridge. | +| `MainActivity : ComponentActivity` | Compose-host. Ставит полноэкранный режим, рендерит `HomeNavGraph`. | +| `VmPhoneController` | Оркестратор. Транспорт (WiFi-клиент через `:transport`), discovery (mDNS/BT scan), протокол, реконнект-логика. Lifecycle. | +| `MercuryBridge` | Обёртка над `pw.binom.mercury:library` для BLE-канала: спаривание, статус очков, **запуск GlassesApp по пакету** через `appMarketLaunch`. | +| `HomeNavGraph` | Compose NavHost. Табы: «Очки» (GlassesStatusScreen) + «Приложения» (AppsListScreen → AppControlScreen). | +| `GlassesStatusScreen : @Composable` | Таб «Очки»: BT-соединение, батарея, статус нашего GlassesApp (через mDNS), кнопка «▶ Запустить наше приложение на очках». | +| `GlassesStatusViewModel` | StateFlow с состоянием Mercury + mDNS discovery + WebSocket. | +| `AppsListScreen : @Composable` | Таб «Приложения»: список профилей (ZonaProfile и т.д.). | +| `AppControlScreen : @Composable` | Обёртка, рендерит `AppProfile.Controls` выбранного профиля. | +| `AppProfile` (interface) | `packageName`, `displayName`, `@Composable Controls(protocol: VmProtocol, modifier)`. | +| `ZonaProfile : AppProfile` | Touchpad + media buttons + keyboard dialog + back. | +| `KeyboardDialog` | Диалог с OutlinedTextField + «Отправить» — каждый символ → `KeyEvent(keyCodeForChar(ch))`. **Не системная клавиатура** (избегаем focus/WindowInsets pain в Compose). | +| `TouchpadController` | Логика тачпада: накапливает тачи, формирует `TouchEvent`, шлёт в `VmProtocol`. | +| `VmProtocol` | Обёртка над `StringTransport`. Кодит/декодит `VmMessage`. | + +### 4.10.3.1. Конкретные DTO `:shared` модуля + +```kotlin +// Базовый sealed interface для всех сообщений +@Serializable +sealed interface VmMessage + +// === PhoneApp → GlassesApp === + +@Serializable @SerialName("touch") +data class TouchEvent(val actions: List) : VmMessage + +@Serializable +data class TouchAction( + val type: TouchType, // DOWN, MOVE, UP + val x: Float, // 0..640 + val y: Float, // 0..480 +) + +@Serializable @SerialName("key") +data class KeyEvent(val keyCode: Int) : VmMessage + +@Serializable @SerialName("launch_app") +data class LaunchAppCommand(val packageName: String) : VmMessage + +@Serializable @SerialName("set_audio_mode") +data class SetAudioMode(val mode: AudioMode) : VmMessage // ON_GLASSES, ON_PHONE + +@Serializable @SerialName("shutdown") +object Shutdown : VmMessage // закрыть гостевое приложение + +// === GlassesApp → PhoneApp === + +@Serializable @SerialName("glasses_status") +data class GlassesStatus( + val batteryPercent: Int? = null, + val wifiSsid: String? = null, + val currentApp: String? = null, // package name или null + val running: Boolean = false, +) : VmMessage + +@Serializable @SerialName("app_crashed") +data class AppCrashedEvent(val packageName: String) : VmMessage + +@Serializable @SerialName("app_launched_ack") +data class AppLaunchedAck(val packageName: String, val success: Boolean) : VmMessage + +// === Общие === + +@Serializable @SerialName("ping") +object Ping : VmMessage + +@Serializable @SerialName("pong") +object Pong : VmMessage + +// === Enums === + +@Serializable +enum class TouchType { DOWN, MOVE, UP } + +@Serializable +enum class AudioMode { ON_GLASSES, ON_PHONE } +``` + +### 4.10.3.2. Тестовая стратегия + +**Фреймворки:** JUnit5 (`org.junit.jupiter`) + MockK + Turbine + kotlinx-coroutines-test. + +**Что покрываем unit-тестами (JVM, без Android):** +- `VmProtocol` — roundtrip encode/decode всех DTO. +- `TouchpadController` — маппинг touch-coordinates, батчинг событий. +- `KeyboardDialog`-логика — перевод строки в keyCode (`KeyEvent.keyCodeForChar('A') = KEYCODE_A`). +- `VmGlassesController` (через `runTest`/`TestDispatcher`) — переходы между `ScreenState`. +- `VmPhoneController` — reconnect backoff логика (1с→2с→4с→8с→16с→30с). +- `TouchAction`/`TouchType` enum сериализация. +- `AudioMode`/`OverlayKind` enum сериализация. +- `LaunchAppCommand` формирование. +- `GlassesStatus`/`AppCrashedEvent` сериализация. + +**Что НЕ покрываем unit-тестами (требует устройства):** +- GL рендер (бинокль). +- VirtualDisplay creation через Shizuku. +- WebSocket транспорт (нужен Ktor runtime). +- mDNS discovery. +- Mercury BLE. +- Activity injection events. + +### 4.10.4. Поток данных + +``` +[Zona на VirtualDisplay] [Тефон] + │ │ + │ кадры в Surface │ + ▼ │ +[VM-сервис: создал display, запустил Zona] │ + │ │ + │ Surface виртуального дисплея │ + ▼ │ +[VmGlassesController] │ + │ │ + │ SurfaceTexture подключен │ + ▼ │ +[VirtualDisplayFrameSource] ──► GL-текстура ──► BinocularRenderer │ + (2 копии в 1280×480) │ + │ │ + ▼ ▼ + [Очки: оптика делит на 2 глаза] + │ + Touch/Key events ────────┘ + │ + WifiTransport.bt() / .wifi() + │ + ▼ + [VmProtocol → UserService → injectInputEvent → Zona] +``` + +### 4.10.5. Звук — два режима + +> **Важно:** к RayNeo X3 Pro **нельзя подключить наушники** (ни BT, ни USB) — конструктивное ограничение очков. Встроенный динамик очков тихий и низкокачественный. Поэтому передача звука **на телефон** (где у пользователя могут быть нормальные наушники) — **основной use case**, а не опция. +> +> Переключатель режима — на телефоне (UI в `TouchpadScreen`), сохраняется в настройках, синхронизируется с очками через `AudioModeCommand`. + +#### Режим 1: звук на очках (встроенный динамик) + +- Сценарий: отладка, тихое проигрывание «для проверки», просмотр без наушников. +- **Подтверждено:** `VirtualDisplay` в Android — **только видео** (документация: `getSurface`/`getDisplay`/etc., ни одного аудио-API). Аудио идёт отдельным каналом через системный `AudioFlinger` в output device. У RayNeo X3 Pro есть встроенный динамик (`STREAM_MUSIC Devices: speaker`). +- `free.zona` воспроизводит звук штатно через свой `AudioTrack` / `MediaPlayer` в `USAGE_MEDIA/CONTENT_TYPE_MOVIE`. Системный микшер очков играет его во встроенный динамик. +- **Наш код не вмешивается.** Работает из коробки. + +#### Режим 2: звук передаётся на телефон (основной сценарий) + +- Сценарий: пользователь смотрит фильм/контент на очках, слушает через наушники, подключённые к **телефону** (BT-гарнитура, USB-C наушники, встроенный динамик телефона). +- Передача аудио — ключевая фича. Без неё приложение малополезно для контента со звуком. + +Цепочка: + +``` +[free.zona на очках] + │ AudioTrack → системный микшер + ▼ +[AudioPlaybackCapture наш] ← AudioRecord с config USAGE_MEDIA + │ PCM-сэмплы + ▼ +[Кодек на очках] ← PCM напрямую или Opus + │ байты + ▼ +[ByteTransport на очках] ← Transport.wifi(url).send(bytes) или .bt(...) + │ + │ WiFi / BT + ▼ +[ByteTransport на телефоне] ← принимаем поток + │ PCM + ▼ +[AudioTrack на телефоне] ← воспроизведение в динамик/наушники + ▼ +[Пользователь слышит звук с телефона] +``` + +Параллельно — на очках **приглушаем** `STREAM_MUSIC` через `AudioManager`, чтобы не было дубля (звук играет и на очках, и на телефоне). + +**Ключевые компоненты:** +- `AudioCaptureService` (в `:app-glasses-vm`) — обёртка над `AudioPlaybackCapture` + `AudioRecord`. Захват PCM с нужной конфигурацией. +- `AudioStreamer` (в `:app-glasses-vm`) — читает из `AudioCaptureService`, кодит (PCM/Opus), шлёт через `ByteTransport`. +- `AudioPlayer` (в `:app-phone-vm`) — принимает PCM, играет через `AudioTrack`. + +**Технические нюансы:** + +| Нюанс | Решение | +|---|---| +| `free.zona` может запретить захват (ALLOW_CAPTURE_BY_SYSTEM вместо _BY_ALL) | При невозможности захвата — переключаемся в режим 1, сообщаем пользователю «приложение не разрешает захват звука». | +| Задержка передачи | WiFi ~30-100 мс (норм), BT ~100-300 мс (ощутимо). Буферизация + визуальный индикатор задержки. | +| Формат передачи | **PCM 44.1 kHz 16 bit stereo** через WiFi (176 KB/s, помещается). Через BT — Opus ~30 KB/s (TBD). | +| **Проигрывание звука на телефоне в фоне** | **Foreground service `AudioPlaybackService`** типа `mediaPlayback` с `PARTIAL_WAKE_LOCK` + `WIFI_MODE_FULL_HIGH_PERF` (см. ниже). | +| **Захват звука на очках в фоне** | **Foreground service `AudioCaptureService`** типа `mediaProjection` для удержания захвата. | + +#### 4.10.5.1. Background playback — foreground service + +**Критичная проблема:** начиная с Android 8.0 (API 26) фоновая работа ограничена. **Китайские прошивки** (HONOR, MIUI, EMUI, ColorOS, OriginOS) **агрессивно убивают фоновые процессы** при выключенном экране. View-mate подтверждает: «HONOR замораживает/убивает процесс в фоне → очки теряют связь (видео паузится) и звук встаёт». + +**Решение (по образцу `view-mate/app-phone/PlaybackService.kt`):** + +```kotlin +// app-phone-vm/.../audio/AudioPlaybackService.kt +class AudioPlaybackService : Service() { + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: WifiManager.WifiLock? = null + + override fun onCreate() { + super.onCreate() + // 1. Foreground notification (ОБЯЗАТЕЛЬНО для Android 8+) + startForeground(NOTIFICATION_ID, buildNotification()) + // 2. Wake lock — CPU не засыпает при выключенном экране + wakeLock = powerManager.newWakeLock(PARTIAL_WAKE_LOCK, "rayneo-vm:audio")?.apply { acquire() } + // 3. WiFi lock — WiFi не отключается (иначе WebSocket-канал падает) + wifiLock = wifiManager.createWifiLock(WIFI_MODE_FULL_HIGH_PERF, "rayneo-vm:wifi")?.apply { acquire() } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_STICKY + override fun onDestroy() { /* release locks */ super.onDestroy() } + override fun onBind(intent: Intent?): IBinder? = null +} +``` + +**Lifecycle:** +- Стартует когда приходит `SetAudioMode(ON_PHONE)` от пользователя. +- Останавливается когда приходит `SetAudioMode(ON_GLASSES)`. +- НЕ всегда-on — только когда нужен режим 2. + +**Аналогичный `AudioCaptureService` на очках** (для удержания `AudioPlaybackCapture`): +- Стартует на очках в `VmService.onCreate` когда активен режим 2. +- Держит `AudioRecord` живым. +- Foreground service типа `mediaProjection` (Android 14+ требует `FOREGROUND_SERVICE_MEDIA_PROJECTION`). + +**Permissions для foreground service (Android 14+, API 34):** + +| Permission | Кто | Зачем | +|---|---|---| +| `FOREGROUND_SERVICE` | Оба приложения | Базовое разрешение для foreground service. | +| `FOREGROUND_SERVICE_MEDIA_PLAYBACK` | PhoneApp | Тип `mediaPlayback` для AudioPlaybackService. | +| `FOREGROUND_SERVICE_MEDIA_PROJECTION` | GlassesApp | Тип `mediaProjection` для AudioCaptureService. | +| `WAKE_LOCK` | PhoneApp | Для `PARTIAL_WAKE_LOCK`. | +| `POST_NOTIFICATIONS` | Оба (Android 13+) | Чтобы foreground notification показывалась. | + +**Важно:** начиная с Android 14 (`targetSdk >= 34`) **каждый foreground service тип должен быть явно объявлен в манифесте** через ``. +| Синхронизация A/V | Звук на телефоне может рассинхронизироваться с картинкой в очках. Решается через таймстампы в PCM-пакетах + буфер на принимающей стороне. | + +--- + +## 5. Нефункциональные требования + +| Параметр | Значение | +|---|---| +| Целевой FPS | ≥ 30, желательно 60 | +| Суммарная задержка «тап на телефоне → реакция в Zona» | < 100 мс | +| Суммарная задержка «кадр VirtualDisplay → очки» | < 1 кадра | +| Энергопотребление | без явных требований, но без лишнего копирования CPU↔GPU | +| Размер виртуального экрана | строго 640×480 | +| Минимальный Android API (GlassesApp) | 32 (Android 12) — соответствует RayNeo X3 Pro | +| Минимальный Android API (PhoneApp) | 26 (Android 8.0) — для совместимости с типичными телефонами | +| Целевое устройство GlassesApp | RayNeo X3 Pro (ARGF20) | +| Версия Kotlin | 1.9.x | +| Compose BOM | 2024.x | +| AGP | 8.x | + +### 5.1. Сетевые настройки + +| Параметр | Значение | +|---|---| +| WebSocket-порт на очках | **8080** (WebSocket + путь `/ws/rayneo-vm`) | +| mDNS service type | **`_rayneo-vm._tcp`** | +| TLS | Нет (локальная сеть, устройства пользователя) | +| Аутентификация | Нет (только локальная сеть) | + +### 5.2. Имена пакетов и namespace + +| Компонент | Namespace | +|---|---| +| GlassesApp | `pw.binom.rayneovm.app.glasses` | +| PhoneApp | `pw.binom.rayneovm.app.phone` | +| `:shared` | `pw.binom.rayneovm.shared` | +| rayneo-mercury новые модули | `pw.binom.mercury.transport`, `pw.binom.mercury.binocularrenderer`, `pw.binom.mercury.shizukuvd` | + +### 5.3. Имя приложения (для пользователя) + +- **GlassesApp:** «RayNeo VM» (лаунчер иконка) +- **PhoneApp:** «RayNeo VM» (лаунчер иконка) + +### 5.3. Permissions + +**GlassesApp (`app-glasses-vm/AndroidManifest.xml`):** + +```xml + + + + + + + + + + +``` + +**PhoneApp (`app-phone-vm/AndroidManifest.xml`):** + +```xml + + + + + + + + + + + + + +``` + +### 5.4. Дефолты (фиксировано для ночной сессии) + +**Версии — latest stable на сентябрь 2026 (по правилу «брать последнее по возможности»):** + +| Что | Версия | Проверено | +|---|---|---| +| Kotlin | **2.4.20** | Maven Central | +| AGP (Android Gradle Plugin) | **9.4.1** | Maven Google | +| Gradle | **9.x** (совместимая с AGP 9.4.1) | Maven Google | +| Compose BOM | **2026.09.00** | Maven Google | +| AndroidX Lifecycle (вкл. `lifecycle-runtime-compose`) | **2.11.0** | Maven Google | +| AndroidX Navigation Compose | **2.10.2** | Maven Google | +| Ktor (server + client) | **3.6.0** | Maven Central | +| kotlinx-coroutines | **1.11.0** | Maven Central | +| kotlinx-serialization | **1.11.0** | Maven Central | +| Shizuku API (`dev.rikka.shizuku:api`) | **13.1.5** | Maven Central | +| rayneo-mercury | `0.1.0-SNAPSHOT` (текущая в Nexus) | локально | + +**Заметки:** +- Compose Compiler **встроен в Kotlin plugin** (с Kotlin 2.0+), не нужна отдельная `kotlinCompilerExtensionVersion`. +- compileSdk для PhoneApp: **36** (Android 16), minSdk: **26** (Android 8.0). +- compileSdk для GlassesApp: **36** (для совместимости с актуальными либами), но фактически работаем на API 32. +- Подпись APK: **debug** (для отладки и ночного тестирования), release потом. +- Иконка приложения: стандартная Android (потом кастомная). + +--- + +## 6. Технические ограничения и риски + +- **Зависимость от Shizuku** — без активного Shizuku GlassesApp не работает. Нужна обработка: показать «запустите Shizuku», предложить инструкцию. +- **Зависимость от связи** — без PhoneApp очки работают только в пассивном режиме (можно смотреть, но не управлять). +- **SurfaceTexture ↔ VirtualDisplay** — в некоторых версиях Android нестабильно. Держать в голове план Б через `ImageReader`. +- **Права shell (UID 2000)** в Shizuku ADB-режиме могут быть недостаточны для части операций. Если упрёмся — план Б: Sui (UID 0). +- **multicast режется на RayNeo** — fallback на скан /24 (как в view-mate). +- **RayNeo X3 Pro vs X2** — view-mate писался под X2. Архитектура та же, но возможны мелкие отличия (дисплей 1280×480 vs другой размер). Тестируем на X3 Pro. + +--- + +## 7. План разработки + +### Объём первой ночи + +**Делаем всё, включая коммуникацию между устройствами. Утром — отладка.** + +Конкретно: +1. Все модули `rayneo-mercury`: `:transport`, `:binocular-renderer`, `:shizuku-vd` (publish в Nexus). +2. Все модули `rayneo-vm`: `:shared`, `:app-glasses-vm`, `:app-phone-vm`. +3. Unit-тесты для всей бизнес-логики. +4. Полная коммуникация: телефон находит очки по mDNS → WebSocket → UI управления Zona на телефоне → тач/клавиши → на очках → через VM-сервис → `injectInputEvent` → в Zona. + +### Этапы (последовательность) + +#### Этап 0. Подготовка `rayneo-mercury` (~2ч) + +1. `:transport` — перенести код из view-mate/lib-core, namespace `pw.binom.mercury.transport`, API `Transport`/`ServerTransport` + DSL `Transport.wifi(...)`/`.bt(...)`/`.wifiServer(...)`/`.btServer()`. Зависимости: Ktor + kotlinx-serialization. Unit-тесты для DSL и контрактов. +2. `:binocular-renderer` — обобщить `BinocularVideoPlayer.kt`, интерфейс `FrameSource` + реализации `VideoFrameSource` (для view-mate, **но реализацию делаем минимальной — заглушка, она view-mate нужна только для совместимости**) и `SurfaceTextureFrameSource` (для rayneo-vm, основная). +3. `:shizuku-vd` — AIDL-биндер + UserService обёртка (`createVirtualDisplay`, `startActivityAsUser`, `injectInputEvent`). +4. Опубликовать все три в локальный Nexus. + +#### Этап 1. Bootstrap `rayneo-vm` (~1ч) + +5. Gradle-проект rayneo-vm с тремя модулями (`:shared`, `:app-glasses-vm`, `:app-phone-vm`). +6. `gradle.properties`, `settings.gradle.kts`, корневой `build.gradle.kts` (плагины: AGP, Kotlin, Compose, kotlinx-serialization). +7. Подключить зависимости из Nexus + Shizuku API + RayNeo SDK. +8. Минимальные `MainActivity` в обоих приложениях + build OK. + +#### Этап 2. Модуль `:shared` (~30мин) + +9. DTO: `VmMessage` (sealed), `TouchEvent`, `TouchAction`, `KeyEvent`, `KeyAction`, `GlassesStatus`, `LaunchAppCommand`, `AudioModeCommand`. +10. Сериализация (kotlinx-serialization, JSON). +11. Unit-тесты: roundtrip сериализации, парсинг всех вариантов `VmMessage`. + +#### Этап 3. `:app-glasses-vm` — рендер (~1ч) + +12. `VmGlassesApp : Application` (инициализация Shizuku listener). +13. `MainActivity` (fullscreen + immersive). +14. `VmGlassesController` — оркестратор. +15. `VirtualDisplayFrameSource : FrameSource` — пока с `SurfaceTexture` от чёрного bitmap (заглушка). +16. `BinocularRenderer` — рендерит в обе половины. +17. Unit-тесты для `FrameSource` контракта. + +#### Этап 4. `:app-glasses-vm` — VM-сервис и VirtualDisplay (~2ч) + +18. AIDL: `IVmShizukuService` с методами `createVirtualDisplay()` / `launchApp(packageName)` / `injectInputEvent(event)`. +19. `VmShizukuUserService : UserService` — реализация в отдельном процессе с правами shell: + - `IDisplayManager.createVirtualDisplay(...)` без `OWN_CONTENT_ONLY`. + - `IActivityTaskManager.startActivityAsUser(...)` для `free.zona`. + - `InputManager.injectInputEvent(...)` для инпута. +20. `VmShizukuService` (клиент) — биндинг через `Shizuku.bindUserService`. +21. Surface виртуального дисплея → через биндер в `MainActivity` → `VirtualDisplayFrameSource`. + +#### Этап 5. `:app-phone-vm` — UI (~2ч) + +22. Material 3 тёмная тема. +23. `HomeScreen` + `HomeViewModel` + bottom navigation. +24. `GlassesStatusScreen` + ViewModel. +25. `AppsListScreen` + ViewModel + `AppProfile`. +26. `AppControlScreen` + ViewModel. +27. `ZonaProfile` (тачпад + медиа-кнопки + клавиатура + назад). +28. `TouchpadView`, `MediaButton`, `KeyboardDialog`, `ConnectionStatus`. +29. `VmPhoneController` (транспорт, протокол, lifecycle). +30. Unit-тесты для ViewModel'ей и `VmProtocol`. + +#### Этап 6. Коммуникация (~2ч) + +31. В GlassesApp: `WifiServerTransport` (поднимаем WebSocket-сервер на очках). +32. mDNS discovery на телефоне (через `:transport` `MdnsScanner`). +33. В PhoneApp: `WifiTransport`-клиент, подключение по найденному URL. +34. `VmProtocol` — обёртка, кодит/декодит VmMessage. +35. Передача `TouchEvent`/`KeyEvent` Phone → Glasses → VM-сервис → `injectInputEvent` в Zona. +36. Передача `LaunchAppCommand`. +37. Авто-реконнект с экспоненциальным backoff + Snackbar. +38. Unit-тесты: маппинг координат, формирование KeyEvent из символов, парсинг входящих сообщений. + +#### Этап 7. Звук — режим 2 (~2ч, если успеем) + +39. `AudioCaptureService` (AudioPlaybackCapture, USAGE_MEDIA). +40. `AudioStreamer` (PCM → ByteTransport). +41. `AudioPlayer` (ByteTransport → AudioTrack на телефоне). +42. `AudioModeCommand` в протоколе. +43. Приглушение звука на очках в режиме 2. + +#### Этап 8. Shizuku UX (~30мин) + +44. `ShizukuStatusChecker` — проверка при старте. +45. `ShizukuActivationScreen` — экран с инструкцией и кнопкой. +46. Listener на `Shizuku.addBinderReceivedListener` для авто-скрытия блока. + +### Что НЕ делаем на этой ночи + +- Аналитику, крэш-репорты. +- Логирование в файл (только logcat). +- Поддержку нескольких одновременных подключений к GlassesApp. +- Сложные жесты (мультитач, pinch-to-zoom). +- A/B-тесты, дизайн-систему вне Material 3. +- CI/CD. +- Авто-установку приложений, которых нет на очках. + +### Что проверяем утром + +- Видна ли Zona в очках. +- Работает ли тачпад (тап/свайг). +- Работают ли медиа-кнопки (⏪⏯⏩). +- Работает ли клавиатура (ввести название фильма, найти). +- Работает ли кнопка назад. +- Работает ли авто-реконнект (выключить WiFi на очках, включить — должно восстановиться). +- Работает ли режим 2 звука (если сделали). + +--- + +## 8. Целевое устройство, гостевое приложение и привилегии + +### 8.1. Устройство (подтверждено через `adb`) + +| Параметр | Значение | +|---|---| +| Manufacturer | RayNeo | +| Модель | ARGF20 | +| Android | 12 | +| API level | 32 | +| ABI | arm64-v8a | +| Физический дисплей | **1 шт**, 1280×480, 60 FPS, density 160 dpi | +| `DisplayInfo.flag` | `FLAG_SECURE`, `FLAG_SUPPORTS_PROTECTED_BUFFERS` | + +### 8.2. Дисплей очков + +RayNeo X3 Pro система видит как **один** дисплей `1280×480`. Оптика очков сама делит картинку на две половины по 640×480 и подаёт каждую на свой глаз. + +- Никаких «двух display id» (L / R) нет — есть один `displayId=0`. +- GlassesApp просто рисует картинку 1280×480, где **левая половина → левый глаз, правая → правый**. +- VirtualDisplay 640×480 по размеру = одной «глазной» половине. Удобно. + +### 8.3. Гостевое приложение + +**Zona** (`free.zona`) — онлайн-кинотеатр. + +| Параметр | Значение | +|---|---| +| Package | `free.zona` | +| `MainActivity` | `free.zona/ru.zona.app.android.MainActivity` | +| `versionName` / `versionCode` | `3.0.61` / `1061` | +| `targetSdk` / `minSdk` | `29` / `21` | +| Размер APK | ~19 MB | +| Intent-filter | `android.intent.action.MAIN` ✓ | + +### 8.4. Shizuku + +Установлен и активирован **Shizuku Manager** (пакет `moe.shizuku.privileged.api`, версия `13.6.0`, ADB-режим, `shizuku_server` от UID 2000). + +Через Shizuku делаем всю работу с системными API: +1. Создание VirtualDisplay **без** `VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY`. +2. Запуск **чужой** Activity на этом дисплее через `IActivityTaskManager.startActivityAsUser`. +3. Передача инпута в гостевое приложение через `InputManager.injectInputEvent`. + +#### 8.4.1. Используемые API Shizuku + +- **UserService** (`Shizuku.bindUserService`) — наш VM-сервис в отдельном процессе с правами shell. +- **Permission** — `Shizuku.requestPermission(REQUEST_CODE)`. +- **Provider** — `rikka.shizuku.ShizukuProvider` в `AndroidManifest.xml`. + +API Shizuku: `dev.rikka.shizuku:api:13.1.5+`. + +#### 8.4.2. Разрешения в `AndroidManifest.xml` + +```xml + + + + + + +``` + +#### 8.4.3. Альтернативы Shizuku (резюме) + +Подробный разбор в v0.5 ТЗ (см. историю изменений). Короткий вывод: + +| Способ | Подходит? | +|---|---| +| **Shizuku (UID 2000) через UserService** | ✓ основной | +| **Root / Sui (UID 0)** | ✓ план Б, требует Magisk | +| `DisplayManager.createVirtualDisplay()` без Shizuku | ✗ (только OWN_CONTENT_ONLY) | +| `MediaProjection` | ✗ (только захват) | +| `Presentation` | ✗ (только своё UI) | +| Подпись ключом RayNeo | ✗ (недостижимо) | + +--- + +## 9. Общая либа `rayneo-mercury` — новые модули + +### 9.1. Репозиторий + +`/home/subochev/common-projects/WORK/rayneo-mercury/` — Android Library, публикуется в локальный Nexus `http://192.168.76.117/repository/caffeine/` под groupId `pw.binom.mercury`. + +### 9.2. Новые модули (добавляем) + +#### `:transport` + +- Назначение: **универсальный** канал связи клиент↔сервер по сети. Не привязан к домену RayNeo, очкам или приложению. +- Источник кода: `view-mate/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/` (`GlassesTransport.kt`, `WifiGlassesTransport.kt`, `GlassesWsClient.kt`, `BtTransportConstants.kt`), `view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/` (`MdnsClient.kt`, `PhoneScan.kt`). +- **Принцип разделения:** либа решает только **КАК** передавать байты/строки (транспорт, реконнект, discovery). **ЧТО** передавать — решает вызывающий (DTO и формат сериализации — в коде приложения, не в либе). +- Namespace: `pw.binom.mercury.transport`. Зависимости — Ktor + kotlinx-serialization. Без KMP (Android Library, как и `library` в rayneo-mercury). + +**Публичный API:** + +```kotlin +// Клиентский транспорт: подключается к серверу, отправляет/получает сообщения +interface Transport { + val name: String + suspend fun connect( + onMessage: suspend (T) -> Unit, + onConnected: suspend () -> Unit, + onDisconnected: suspend () -> Unit, + ) + suspend fun send(message: T) + fun close() +} + +// Серверный транспорт: принимает подключения, шлёт по connId +interface ServerTransport { + val name: String + suspend fun accept( + onMessage: suspend (connId: String, message: T) -> Unit, + onConnected: (connId: String) -> Unit, + onDisconnected: (connId: String) -> Unit, + ) + suspend fun send(connId: String, message: T) + fun close() +} + +// Удобные typealias'ы для типичных кейсов +typealias StringTransport = Transport // JSON-строки, текст +typealias ByteTransport = Transport // бинарный (protobuf, msgpack, raw) +``` + +**DSL-фабрики** (extension functions на `Transport.Companion` — короткий идиоматичный вызов): + +```kotlin +Transport.wifi("ws://192.168.1.42:8080/ws") // StringTransport, клиент +Transport.wifiServer(port = 8080) // ServerTransport, сервер +Transport.bt("00:11:22:33:44:55") // StringTransport, клиент +Transport.btServer() // ServerTransport, сервер +``` + +Это просто синтаксический сахар над конструкторами конкретных классов — никакой магии. + +**Реализации:** + +| Класс | Роль | Транспорт | +|---|---|---| +| `WifiTransport(url: String)` | клиент | WebSocket через Ktor | +| `WifiServerTransport(port: Int)` | сервер | WebSocket через Ktor | +| `BtTransport(macAddress: String)` | клиент | Bluetooth SPP / BLE | +| `BtServerTransport` | сервер | Bluetooth SPP / BLE | + +**Discovery (отдельные классы, не часть `Transport`):** + +| Класс | Что делает | +|---|---| +| `MdnsScanner(serviceType: String, fallbackScan: Boolean = true)` | mDNS discovery + fallback на скан /24 (на некоторых устройствах multicast режется) | +| `BtScanner` | Bluetooth discovery | + +**Чего в либе НЕТ** (намеренно): +- Никаких DTO с `@SerialName("touch")` / `GlassesToHost` / `HostToGlasses`. +- Никакого знания про режимы (`MOVIE`, `CHAT`) или жесты. +- Никаких упоминаний view-mate, RayNeo или конкретного приложения в именах/комментариях. + +**Использование в rayneo-vm (в коде приложения, не в либе):** + +```kotlin +// Свои DTO и протокол — на стороне приложения +@Serializable sealed interface VmMessage +@Serializable @SerialName("touch") data class TouchEvent(...) : VmMessage +@Serializable @SerialName("key") data class KeyEvent(...) : VmMessage +@Serializable @SerialName("status") data class GlassesStatus(...) : VmMessage + +// Свой адаптер поверх транспорта +class VmProtocol(transport: StringTransport) { + val incoming: Flow = callbackFlow { + transport.connect( + onMessage = { json -> trySend(Json.decodeFromString(json)) }, + onConnected = { /* update UI */ }, + onDisconnected = { /* update UI */ }, + ) + awaitClose { transport.close() } + } + suspend fun send(msg: VmMessage) = transport.send(Json.encodeToString(msg)) +} +``` + +- Maven-координаты: `pw.binom.mercury:transport:`. + +#### `:binocular-renderer` + +- Назначение: EGL-бинокулярный рендерер на очках. +- Источник кода: `view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt`. +- Адаптация: выделить интерфейс `FrameSource`. Реализации: + - `VideoFrameSource` (для view-mate, ExoPlayer → Surface → текстура). + - `SurfaceTextureFrameSource` (для rayneo-vm, VirtualDisplay surface → SurfaceTexture → текстура). +- Maven-координаты: `pw.binom.mercury:binocular-renderer:`. + +#### `:shizuku-vd` (опционально) + +- Назначение: удобная обёртка над Shizuku UserService для создания VirtualDisplay и запуска Activity. +- Реализация: биндинг между основным приложением и UserService через AIDL. +- В VM-сервисе: вся логика создания display + запуска Activity + проброс Surface. +- Maven-координаты: `pw.binom.mercury:shizuku-vd:`. + +### 9.3. Версионирование и публикация + +- Версия модулей берётся из `gradle.properties` (`mercuryVersion`, по аналогии с существующим `library`). +- Публикация: `./gradlew :transport:publish :binocular-renderer:publish` → Nexus `caffeine`. +- Подключение в rayneo-vm: + ```kotlin + implementation("pw.binom.mercury:transport:$mercuryVersion") + implementation("pw.binom.mercury:binocular-renderer:$mercuryVersion") + implementation("pw.binom.mercury:shizuku-vd:$mercuryVersion") + ``` + +### 9.4. Что делаем с view-mate + +**Ничего.** view-mate продолжает жить со своим lib-core. Миграция на rayneo-mercury — задача другого агента (по просьбе пользователя). + +--- + +## 10. Открытые вопросы + +### Закрытые (решены) + +- [x] Гостевое приложение — **Zona (`free.zona`)**, см. 8.3. +- [x] Целевое устройство — RayNeo X3 Pro (ARGF20, Android 12, API 32), см. 8.1. +- [x] Display id очков — **один** (`displayId=0`, 1280×480), оптика делит на два глаза по 640×480, см. 8.2. +- [x] Привилегии — через **Shizuku**, см. 8.4. +- [x] Альтернативы Shizuku — единственный практический путь, см. 8.4.3. +- [x] Связь телефон ↔ очки — WebSocket по WiFi + BT-fallback через модуль `:transport`, см. 4.7. +- [x] Общая либа — rayneo-mercury (новые модули `:transport`, `:binocular-renderer`, `:shizuku-vd`), см. 9. +- [x] Протокол — расширяем существующий из view-mate (TouchEvent, KeyEvent, GlassesStatus), см. 4.7.2. +- [x] Звук: 2 режима (на очках / на телефоне), переключатель в PhoneApp, см. 4.10.5. +- [x] UI PhoneApp: Material 3, тёмная тема, bottom navigation, тачпад с серым фоном и точкой касания. +- [x] Shizuku UX: блок + кнопка активации + инструкция. +- [x] Потеря связи: авто-реконнект с экспоненциальным backoff + Snackbar. +- [x] Архитектура UI: MVVM + ViewModel + StateFlow. +- [x] Тесты: unit на JVM для всего что можно. + +### Открытые (проверим экспериментально утром) + +- [ ] Подтвердить экспериментально, что через Shizuku UserService создаётся VirtualDisplay **без** OWN_CONTENT_ONLY и на нём реально стартует `free.zona`. +- [ ] Режим 2 звука: подтвердить, что `AudioPlaybackCapture` работает на `free.zona` (не запрещает ли она захват). Если запрещает — fallback на режим 1 + сообщение. +- [ ] Режим 2 звука: PCM напрямую или сжимать (Opus)? PCM проще, но ~176 KB/s; Opus ~30 KB/s, но добавляет кодек. Уточнить после замера задержки. +- [ ] Режим 2 звука: использовать ли BT-канал для передачи звука (медленнее) или только WiFi (быстрее)? +- [ ] Какие требования по задержке и FPS — есть ли порог «некомфортно»? + +### Отложенные (будущее, не сейчас) + +- [ ] Нужно ли давать пользователю UI-управление на самих очках (жесты/кнопки), или только с телефона? +- [ ] Нужен ли multi-touch (pinch-to-zoom) или достаточно single-touch? +- [ ] Поддержка других приложений (Jellyfin и т.п.) — добавлять новые `AppProfile`. +- [ ] Подтвердить, что RayNeo X3 Pro ведёт себя так же, как X2 (на котором написан view-mate), по части дисплея, жестов, multicast и т.д. + +--- + +## 11. История изменений + +| Дата | Версия | Что изменилось | +|---|---|---| +| 2026-09-25 | 0.1 | Первичный черновик | +| 2026-09-25 | 0.2 | Уточнены: целевое устройство, дисплей, гостевое приложение = Zona. | +| 2026-09-25 | 0.3 | Добавлен раздел про Shizuku. | +| 2026-09-25 | 0.4 | Архитектура обновлена под Shizuku UserService. | +| 2026-09-25 | 0.5 | Раздел про альтернативы Shizuku, отложенный инпут. | +| 2026-09-25 | 0.6 | Архитектура расширена: два подмодуля (GlassesApp + PhoneApp) + общая либа `rayneo-mercury` с новыми модулями `:transport`, `:binocular-renderer`, `:shizuku-vd`. Связь через WebSocket, протокол расширен TouchEvent/KeyEvent/GlassesStatus. Источник переиспользования — `/home/subochev/common-projects/WORK/ai/view-mate/`. view-mate не трогаем. | +| 2026-09-25 | 0.7 | Раздел 9.2 (`:transport`) переработан: либа теперь универсальная, не привязана к RayNeo/view-mate. API — `Transport` / `ServerTransport` (generic по типу сообщения) + typealias'ы `StringTransport` / `ByteTransport`. Протокол (DTO, сериализация) вынесен из либы в код rayneo-vm. Либа не знает про домен, режимы, жесты — только «как передавать байты». | +| 2026-09-25 | 0.8 | DSL-фабрики `Transport.wifi(...)` / `Transport.bt(...)` / `Transport.wifiServer(...)` / `Transport.btServer()` (extension functions на `Transport.Companion`) — короткий идиоматичный Kotlin-вызов. | +| 2026-09-25 | 0.9 | Добавлен раздел 4.10 «Структура приложения rayneo-vm»: 3 Gradle-модуля (`:app-glasses-vm`, `:app-phone-vm`, `:shared`), перечислены классы и поток данных. UI на PhoneApp — Compose. DTO живут в `:shared` (не в rayneo-mercury). | +| 2026-09-25 | 0.10 | Добавлен раздел 4.10.5 «Звук — два режима»: режим 1 (звук на очках, из коробки), режим 2 (через `AudioPlaybackCapture` + `ByteTransport` → `AudioTrack` на телефоне). Описаны компоненты `AudioCaptureService`, `AudioStreamer`, `AudioPlayer`, приглушение звука на очках в режиме 2, риски задержки и ALLOW_CAPTURE_*. | +| 2026-09-25 | 0.11 | Раздел 4.10.5 уточнён: режим 1 подтверждён эмпирически (`dumpsys audio` на устройстве — `STREAM_MUSIC Devices: speaker, remote_submix`, BT A2DP sink и USB-аудио доступны). VirtualDisplay в Android — только видео, аудио идёт через AudioFlinger в обычный output device. | +| 2026-09-25 | 0.12 | Звук: зафиксировано, что к RayNeo X3 Pro **нельзя подключить наушники** — конструктивное ограничение. Режим 2 (передача звука на телефон) — **основной use case**, режим 1 — для отладки/тихого просмотра. | +| 2026-09-25 | 0.13 | Раздел 4.6 расширен: UI PhoneApp = HomeScreen с двумя табами («Очки» статус, «Приложения» список). Введён интерфейс `AppProfile` для специфичного UI каждого приложения. ZonaProfile: тачпад + ⏪⏯⏩⌨← + диалог клавиатуры с полем ввода и кнопкой «Отправить». Новые клавиши — медиа (`KEYCODE_MEDIA_*`) и `KEYCODE_BACK` — через тот же `KeyEvent` DTO. Тап на приложение в списке → `LaunchAppCommand` → VM-сервис запускает/фокусирует Activity → переход на `AppControlScreen(profile)`. | +| 2026-09-25 | 0.14 | Раздел 4.6.7-4.6.10: зафиксированы design-решения (Material 3, тёмная тема, bottom nav, тачпад с серым фоном + точкой касания), Shizuku UX (блок + кнопка активации), поведение при потере связи (авто-реконнект с backoff + Snackbar), архитектура UI (MVVM + ViewModel + StateFlow). Раздел 7 (план разработки) переписан: «делаем всё за ночь, утром отладка» с 8 этапами и конкретными unit-тестами. Открытые вопросы структурированы: закрытые / открытые экспериментальные / отложенные. | +| 2026-09-25 | 0.15 | Добавлен раздел 4.6.6: интеграция с фирменным BLE-протоколом **RayNeo Mercury** (`pw.binom.mercury:library`) — служебный канал для спаривания, статуса очков, и **запуска нашего GlassesApp по пакету через `appMarketLaunch`**. `GlassesStatusScreen` дополнен: показывает статус BLE-соединения, батарею, и **кнопку «▶ Запустить наше приложение на очках»** если WebSocket-канал не поднялся (определяем через mDNS discovery). Запуск гостевого приложения (Zona) — через WebSocket `LaunchAppCommand`. Зависимости `app-phone-vm` расширены `:library`. | +| 2026-09-25 | 0.16 | Добавлен раздел 4.6.11 «Обработка краша гостевого приложения»: детектирование через `IActivityTaskManager.registerTaskStackListener(...)` в VM-сервисе, событие `AppCrashedEvent` через WebSocket, на PhoneApp — Snackbar + `popBackStack()` на `AppsListScreen`. **Без автоперезапуска** (по решению пользователя — пользователь сам решает). Таблица что происходит когда что падает (Activity/процесс/VM-сервис/GlassesApp). Edge cases для VM-сервиса и GlassesApp отмечены как отложенные. | +| 2026-09-25 | 0.17 | Добавлен раздел 4.6.12 «Состояния экрана очков» — 5 состояний через `OverlayFrameSource`: «Всё работает», «Нет связи» (overlay поверх кадра), «Приложение не выбрано» (чёрный + надпись), «Shizuku не активен» (чёрный + инструкция), «Стартует». Добавлен раздел 5 «Нефункциональные требования» с подсекциями: сетевые настройки (WebSocket 8080, mDNS `_rayneo-vm._tcp`), имена пакетов/namespace, имя приложения «RayNeo VM», технические дефолты (Kotlin 1.9.x, Compose BOM 2024.x, AGP 8.x, rayneo-mercury 0.1.0-SNAPSHOT, Shizuku 13.1.5). | +| 2026-09-25 | 0.18 | Разделы 4.1 и 4.2 переписаны: GlassesApp UI тоже на **Jetpack Compose** (как PhoneApp). GL-рендер встроен через `AndroidView { SurfaceView }` (паттерн из view-mate `BinocularVideoPlayer.kt`). Публичный API `:binocular-renderer` — `@Composable fun BinocularRendererView(frameSource, modifier)`. Compose-дерево GlassesApp: `Box` с двумя слоями — GL-рендер (BinocularRendererView) для `Running` состояния + Compose overlay для всех остальных состояний. | +| 2026-09-25 | 0.21 | Раздел 4.10.5: добавлен 4.10.5.1 «Background playback — foreground service» с детальным описанием `AudioPlaybackService` (PhoneApp, тип `mediaPlayback`, `PARTIAL_WAKE_LOCK` + `WIFI_MODE_FULL_HIGH_PERF`) и `AudioCaptureService` (GlassesApp, тип `mediaProjection`). Паттерн взят из view-mate/app-phone/PlaybackService.kt — без него китайские ROM (HONOR/MIUI/EMUI/ColorOS) убивают процесс при выключенном экране. Обновлён 5.3 «Permissions»: добавлены `FOREGROUND_SERVICE_MEDIA_PLAYBACK` (PhoneApp), `FOREGROUND_SERVICE_MEDIA_PROJECTION` (GlassesApp), `WAKE_LOCK` (PhoneApp). | diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8e5c18b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +android.defaults.buildfeatures.buildconfig=false +kotlin.code.style=official + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..fa4ed51 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..77cb29c --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,67 @@ +[versions] +kotlin = "2.4.20" +agp = "9.4.1" +coroutines = "1.11.0" +kotlinxSerialization = "1.11.0" +ktor = "3.6.0" +lifecycle = "2.11.0" +activityCompose = "1.11.0" +composeBom = "2026.09.00" +shizukuApi = "13.1.5" +navCompose = "2.10.2" +mercury = "0.1.0-SNAPSHOT" +mercuryTransport = "0.1.0-SNAPSHOT" +mercuryBinocular = "0.1.0-SNAPSHOT" +mercuryShizuku = "0.1.0-SNAPSHOT" + +[libraries] +# kotlinx +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# Ktor (используется в :shared для тестов и в качестве API для клиента) +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +# AndroidX +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navCompose" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.15.0" } + +# Shizuku +shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizukuApi" } + +# rayneo-mercury (библиотеки) +mercury-library = { module = "pw.binom.mercury:rayneo-mercury", version.ref = "mercury" } +mercury-transport = { module = "pw.binom.mercury:rayneo-mercury-transport", version.ref = "mercuryTransport" } +mercury-binocular = { module = "pw.binom.mercury:rayneo-mercury-binocular-renderer", version.ref = "mercuryBinocular" } +mercury-shizuku = { module = "pw.binom.mercury:rayneo-mercury-shizuku-vd", version.ref = "mercuryShizuku" } + +# Test +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version = "5.11.4" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version = "5.11.4" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "1.11.4" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +mockk = { module = "io.mockk:mockk", version = "1.13.16" } +turbine = { module = "app.cash.turbine:turbine", version = "1.2.0" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..eb84db6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..c5fff5e --- /dev/null +++ b/local.properties @@ -0,0 +1 @@ +sdk.dir=/home/subochev/Android/Sdk diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3c5b3b6 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,35 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { + name = "caffeine" + url = uri("http://192.168.76.117/repository/caffeine/") + isAllowInsecureProtocol = true + credentials { + username = providers.gradleProperty("nexusUser").getOrElse("admin") + password = providers.gradleProperty("nexusPassword").getOrElse("admin123") + } + } + } +} + +rootProject.name = "rayneo-vm" +include(":shared") +include(":app-glasses-vm") +include(":app-phone-vm") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..595e3f5 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "pw.binom.rayneovm.shared" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.json) + + api(libs.mercury.transport) + + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.kotlin.test) // need for kotlin.test.Test + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/shared/src/main/AndroidManifest.xml b/shared/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/shared/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt new file mode 100644 index 0000000..36d852d --- /dev/null +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -0,0 +1,113 @@ +package pw.binom.rayneovm.shared + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Корневой sealed-интерфейс сообщения между телефоном и очками. + * + * Все DTO — сериализуемые в JSON. Полиморфная дискриминация по полю `type` + * (настраивается в [VmProtocol]). + * + * Потоки: + * - Phone → Glasses: [TouchEvent], [KeyEvent], [LaunchAppCommand], [SetAudioMode], [Shutdown] + * - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck] + * - Двунаправленные: [Ping], [Pong] + */ +@Serializable +sealed interface VmMessage + +// ===================================================================== +// Phone → Glasses +// ===================================================================== + +@Serializable +@SerialName("touch") +data class TouchEvent( + val actions: List, +) : VmMessage + +@Serializable +data class TouchAction( + val type: TouchType, + /** X в координатах виртуального дисплея гостевого приложения (0..width-1). */ + val x: Float, + /** Y в координатах виртуального дисплея гостевого приложения (0..height-1). */ + val y: Float, + /** Pointer ID (для multi-touch; для простого тачпада всегда 0). */ + val pointerId: Int = 0, +) + +@Serializable +enum class TouchType { DOWN, MOVE, UP, CANCEL } + +@Serializable +@SerialName("key") +data class KeyEvent( + /** KeyCode из android.view.KeyEvent. */ + val keyCode: Int, + /** MotionEvent action: ACTION_DOWN = 0, ACTION_UP = 1. */ + val action: Int, +) : VmMessage { + companion object { + const val ACTION_DOWN = 0 + const val ACTION_UP = 1 + } +} + +@Serializable +@SerialName("launch_app") +data class LaunchAppCommand( + val packageName: String, +) : VmMessage + +@Serializable +@SerialName("set_audio_mode") +data class SetAudioMode( + val mode: AudioMode, +) : VmMessage + +@Serializable +enum class AudioMode { ON_GLASSES, ON_PHONE } + +@Serializable +@SerialName("shutdown") +data object Shutdown : VmMessage + +// ===================================================================== +// Glasses → Phone +// ===================================================================== + +@Serializable +@SerialName("glasses_status") +data class GlassesStatus( + val batteryPercent: Int? = null, + val wifiSsid: String? = null, + val currentApp: String? = null, + val running: Boolean = false, +) : VmMessage + +@Serializable +@SerialName("app_crashed") +data class AppCrashedEvent( + val packageName: String, +) : VmMessage + +@Serializable +@SerialName("app_launched") +data class AppLaunchedAck( + val packageName: String, + val success: Boolean, +) : VmMessage + +// ===================================================================== +// Bidirectional +// ===================================================================== + +@Serializable +@SerialName("ping") +data object Ping : VmMessage + +@Serializable +@SerialName("pong") +data object Pong : VmMessage diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmProtocol.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmProtocol.kt new file mode 100644 index 0000000..bd16372 --- /dev/null +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmProtocol.kt @@ -0,0 +1,51 @@ +package pw.binom.rayneovm.shared + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Протокол общения Phone ↔ Glasses. + * + * Надстройка над JSON-сериализацией для [VmMessage]. + * Не делает никакой логики доставки — только упаковка/распаковка. + * + * Используется поверх любого [pw.binom.mercury.transport.StringTransport] (WebSocket, BT). + */ +object VmProtocol { + + val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + classDiscriminator = "type" + } + + /** + * Сериализует сообщение в JSON-строку. + */ + fun encode(message: VmMessage): String = + json.encodeToString(message) + + /** + * Десериализует JSON-строку в [VmMessage]. + * + * @throws VmProtocolException если JSON невалидный или тип неизвестен + */ + inline fun decode(jsonString: String): T = + try { + json.decodeFromString(jsonString) + } catch (t: Throwable) { + throw VmProtocolException("Cannot decode VmMessage: ${t.message}", t) + } + + /** + * Десериализует JSON-строку в базовый [VmMessage] (полиморфная дискриминация). + */ + fun decodeAny(jsonString: String): VmMessage = + try { + json.decodeFromString(jsonString) + } catch (t: Throwable) { + throw VmProtocolException("Cannot decode VmMessage: ${t.message}", t) + } +} + +class VmProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt b/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt new file mode 100644 index 0000000..dd8321d --- /dev/null +++ b/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt @@ -0,0 +1,136 @@ +package pw.binom.rayneovm.shared + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VmProtocolTest { + + @Test + fun `TouchEvent roundtrip`() { + val msg = TouchEvent( + actions = listOf( + TouchAction(TouchType.DOWN, 100f, 200f, pointerId = 0), + TouchAction(TouchType.MOVE, 110f, 205f, pointerId = 0), + TouchAction(TouchType.UP, 120f, 210f, pointerId = 0), + ) + ) + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(msg, decoded) + } + + @Test + fun `KeyEvent roundtrip`() { + val msg = KeyEvent(keyCode = 66, action = KeyEvent.ACTION_DOWN) // 66 = ENTER + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(msg, decoded) + assertEquals(66, decoded.keyCode) + } + + @Test + fun `LaunchAppCommand roundtrip`() { + val msg = LaunchAppCommand(packageName = "free.zona") + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(msg, decoded) + assertEquals("free.zona", decoded.packageName) + } + + @Test + fun `SetAudioMode roundtrip`() { + val msg = SetAudioMode(mode = AudioMode.ON_PHONE) + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(msg, decoded) + } + + @Test + fun `GlassesStatus roundtrip`() { + val msg = GlassesStatus( + batteryPercent = 87, + wifiSsid = "HomeNetwork", + currentApp = "free.zona", + running = true, + ) + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(msg, decoded) + } + + @Test + fun `AppCrashedEvent roundtrip`() { + val msg = AppCrashedEvent(packageName = "free.zona") + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(msg, decoded) + } + + @Test + fun `AppLaunchedAck roundtrip`() { + val msg = AppLaunchedAck(packageName = "free.zona", success = true) + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(msg, decoded) + } + + @Test + fun `Ping and Pong encode as data object`() { + assertEquals("""{"type":"ping"}""", VmProtocol.encode(Ping)) + assertEquals("""{"type":"pong"}""", VmProtocol.encode(Pong)) + } + + @Test + fun `decodeAny for TouchEvent uses type discriminator`() { + val json = """{"type":"touch","actions":[{"type":"DOWN","x":10,"y":20,"pointerId":0}]}""" + val msg = VmProtocol.decodeAny(json) + assertTrue(msg is TouchEvent) + assertEquals(1, msg.actions.size) + assertEquals(TouchType.DOWN, msg.actions[0].type) + assertEquals(10f, msg.actions[0].x) + } + + @Test + fun `decodeAny for LaunchAppCommand`() { + val json = """{"type":"launch_app","packageName":"free.zona"}""" + val msg = VmProtocol.decodeAny(json) + assertTrue(msg is LaunchAppCommand) + assertEquals("free.zona", msg.packageName) + } + + @Test + fun `decodeAny for AppCrashedEvent`() { + val json = """{"type":"app_crashed","packageName":"free.zona"}""" + val msg = VmProtocol.decodeAny(json) + assertTrue(msg is AppCrashedEvent) + assertEquals("free.zona", msg.packageName) + } + + @Test + fun `Shutdown data object roundtrip`() { + val encoded = VmProtocol.encode(Shutdown) + val decoded = VmProtocol.decodeAny(encoded) + assertEquals(Shutdown, decoded) + } + + @Test + fun `SetAudioMode ON_GLASSES roundtrip`() { + val msg = SetAudioMode(mode = AudioMode.ON_GLASSES) + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decodeAny(encoded) + assertEquals(msg, decoded) + assertEquals(AudioMode.ON_GLASSES, (decoded as SetAudioMode).mode) + } + + @Test + fun `GlassesStatus without fields default to nulls and false`() { + val msg = GlassesStatus() + val encoded = VmProtocol.encode(msg) + val decoded = VmProtocol.decode(encoded) + assertEquals(null, decoded.batteryPercent) + assertEquals(null, decoded.wifiSsid) + assertEquals(null, decoded.currentApp) + assertEquals(false, decoded.running) + } +}