This commit is contained in:
2026-09-25 13:24:56 +03:00
parent 45066a65a1
commit 987f9e5547
47 changed files with 4998 additions and 1 deletions
+81
View File
@@ -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()
@@ -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
}
}
@@ -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"
}
}
@@ -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>