init
This commit is contained in:
@@ -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<T> / ServerTransport<T>, 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 <glasses-serial> install -r app-glasses-vm/build/outputs/apk/debug/app-glasses-vm-debug.apk
|
||||||
|
adb -s <phone-serial> 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)
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Внутренний проект.
|
||||||
|
|||||||
@@ -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<Exec>("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)
|
||||||
|
}
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:glEsVersion="0x00020000"
|
||||||
|
android:required="true" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.type.embedded"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".GlassesVmApp"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:theme="@style/Theme.RayNeoVm"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
tools:targetApi="36">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/Theme.RayNeoVm">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Foreground service: WebSocket server + mDNS -->
|
||||||
|
<service
|
||||||
|
android:name=".VmService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
|
||||||
|
<!-- Shizuku UserService для системных вызовов -->
|
||||||
|
<service
|
||||||
|
android:name=".VmShizukuService"
|
||||||
|
android:exported="true"
|
||||||
|
android:process=":shizuku_vd" />
|
||||||
|
<meta-data android:name="com.rayneo.mercury.app" android:value="true" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>? = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
+159
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -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>(GlassesScreenState.Starting)
|
||||||
|
val state: StateFlow<GlassesScreenState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _frameSource = MutableStateFlow<FrameSource?>(null)
|
||||||
|
val frameSource: StateFlow<FrameSource?> = _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<VmGlassesController> {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FrameSource>(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">RayNeo VM</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.RayNeoVm" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/black</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/black</item>
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -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<Exec>("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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".PhoneVmApp"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:theme="@style/Theme.RayNeoVm"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
tools:targetApi="36">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/Theme.RayNeoVm">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Foreground service для фонового воспроизведения аудио -->
|
||||||
|
<service
|
||||||
|
android:name=".audio.AudioPlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <service> декларацией и не вызывается.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(MercuryState.PermissionsMissing)
|
||||||
|
val state: StateFlow<MercuryState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var lastScanResults: List<DiscoveredGlasses> = emptyList()
|
||||||
|
|
||||||
|
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
|
||||||
|
val requiredPermissions: Array<String> = 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<DiscoveredGlasses>()
|
||||||
|
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<MercuryController> {
|
||||||
|
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<DiscoveredGlasses>) : MercuryState
|
||||||
|
|
||||||
|
/** Устанавливаем пару + GATT. */
|
||||||
|
data class Connecting(val device: DiscoveredGlasses) : MercuryState
|
||||||
|
|
||||||
|
/** GATT-сессия жива, очки отвечают. */
|
||||||
|
data class Connected(
|
||||||
|
val device: DiscoveredGlasses,
|
||||||
|
val batteryPercent: Int? = null,
|
||||||
|
) : MercuryState
|
||||||
|
}
|
||||||
@@ -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>(ConnectionState.Searching)
|
||||||
|
val state: StateFlow<ConnectionState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _glassesStatus = MutableStateFlow<GlassesStatusInfo?>(null)
|
||||||
|
val glassesStatus: StateFlow<GlassesStatusInfo?> = _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<String?>(null)
|
||||||
|
val appCrashed: StateFlow<String?> = _appCrashed.asStateFlow()
|
||||||
|
|
||||||
|
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
|
||||||
|
val appLaunched: StateFlow<AppLaunchedAck?> = _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<VmPhoneController> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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 -> "нет связи"
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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<Offset?>(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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">RayNeo VM</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.RayNeoVm" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/black</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/black</item>
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+1369
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
Vendored
BIN
Binary file not shown.
+9
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
Vendored
+82
@@ -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%
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
sdk.dir=/home/subochev/Android/Sdk
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||||
@@ -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<TouchAction>,
|
||||||
|
) : 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
|
||||||
@@ -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 <reified T : VmMessage> decode(jsonString: String): T =
|
||||||
|
try {
|
||||||
|
json.decodeFromString<T>(jsonString)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
throw VmProtocolException("Cannot decode VmMessage: ${t.message}", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Десериализует JSON-строку в базовый [VmMessage] (полиморфная дискриминация).
|
||||||
|
*/
|
||||||
|
fun decodeAny(jsonString: String): VmMessage =
|
||||||
|
try {
|
||||||
|
json.decodeFromString<VmMessage>(jsonString)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
throw VmProtocolException("Cannot decode VmMessage: ${t.message}", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VmProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
|
||||||
@@ -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<TouchEvent>(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<KeyEvent>(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<LaunchAppCommand>(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<SetAudioMode>(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<GlassesStatus>(encoded)
|
||||||
|
assertEquals(msg, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AppCrashedEvent roundtrip`() {
|
||||||
|
val msg = AppCrashedEvent(packageName = "free.zona")
|
||||||
|
val encoded = VmProtocol.encode(msg)
|
||||||
|
val decoded = VmProtocol.decode<AppCrashedEvent>(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<AppLaunchedAck>(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<GlassesStatus>(encoded)
|
||||||
|
assertEquals(null, decoded.batteryPercent)
|
||||||
|
assertEquals(null, decoded.wifiSsid)
|
||||||
|
assertEquals(null, decoded.currentApp)
|
||||||
|
assertEquals(false, decoded.running)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user