init
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user