From 4743427756cb9f4ba625afe606131d596859ed3c Mon Sep 17 00:00:00 2001 From: subochev Date: Thu, 1 Oct 2026 11:26:37 +0300 Subject: [PATCH] =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2:=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=82=D1=83=D0=BC=D0=B1=D0=BB=D0=B5=D1=80=20=D0=BD=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=9F=D1=83=D0=BB=D1=8C=D1=82=D0=B5=C2=BB=20+=20=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D1=88=D0=BB=D1=8E=D1=82=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared: новое сообщение ScreenState(on) от очков телефону - glasses: слушаем ACTION_SCREEN_ON/OFF (PowerManager.isInteractive), шлём состояние при подключении телефона и на каждое изменение; после ScreenControl — актуализация - phone: VmPhoneController.screenOn (StateFlow), оптимистичное обновление в setScreen - phone: на экране «Пульт» одна кнопка-тумблер «Погасить/Разбудить экран очков», подпись отражает реальное состояние, disabled без Канала 1 --- .../pw/binom/rayneovm/glasses/VmService.kt | 65 +++++++++++++++++++ .../phone/protocol/VmPhoneController.kt | 11 ++++ .../binom/rayneovm/phone/ui/RemoteScreen.kt | 23 +++++++ .../pw/binom/rayneovm/shared/VmMessage.kt | 12 ++++ 4 files changed, 111 insertions(+) diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index c3f286a..5f64e2c 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -4,14 +4,17 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.ServiceInfo import android.media.AudioManager import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build import android.os.IBinder +import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat import androidx.lifecycle.LifecycleService @@ -46,6 +49,7 @@ import pw.binom.rayneovm.shared.CursorClick import pw.binom.rayneovm.shared.CursorMove import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.ScreenControl +import pw.binom.rayneovm.shared.ScreenState import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.audio.AudioConfig import pw.binom.rayneovm.shared.audio.AudioDatagram @@ -118,6 +122,7 @@ class VmService : LifecycleService() { startAudioServer() startAudioPump() registerMdns() + registerScreenReceiver() // Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся // или нам выдали permission) — чтобы телефон не опрашивал вручную. lifecycleScope.launch { @@ -169,6 +174,9 @@ class VmService : LifecycleService() { */ private val currentConn = java.util.concurrent.atomic.AtomicReference(null) + /** Ресивер `ACTION_SCREEN_ON/OFF`: сообщаем телефону реальное состояние экрана очков. */ + private var screenReceiver: BroadcastReceiver? = null + private fun startServer() { server = Transports.wifiServer(port = WS_PORT, path = WS_PATH) lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { @@ -184,6 +192,8 @@ class VmService : LifecycleService() { controller.onPhoneConnected() // И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа). lifecycleScope.launch { sendShizukuStatus(connId) } + // ...и реальное состояние экрана (для кнопки-тумблера на телефоне). + lifecycleScope.launch { sendScreenState(connId) } }, onDisconnected = { connId -> Log.i(TAG, "Phone disconnected: $connId") @@ -238,6 +248,11 @@ class VmService : LifecycleService() { is ScreenControl -> { Log.i(TAG, "ScreenControl: sleep=${msg.sleep}") controller.setScreen(msg.sleep) + // Экран переключается чуть позже инжекта — сообщаем телефону актуальное состояние. + lifecycleScope.launch { + kotlinx.coroutines.delay(600) + pushScreenState() + } } is SetText -> { Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}") @@ -461,6 +476,55 @@ class VmService : LifecycleService() { sendShizukuStatus(connId) } + /** Текущее состояние экрана очков (true — экран включён). */ + private fun currentScreenOn(): Boolean = + (getSystemService(POWER_SERVICE) as? PowerManager)?.isInteractive ?: true + + /** Отправляет состояние экрана конкретной сессии (ответ на подключение / изменение). */ + private suspend fun sendScreenState(connId: String) { + val on = currentScreenOn() + val json = VmProtocol.encode(ScreenState(on = on)) + try { + server?.send(connId, json) + } catch (t: Throwable) { + Log.w(TAG, "Не удалось отправить ScreenState: ${t.message}") + } + Log.i(TAG, "ScreenState: on=$on") + } + + /** Отправляет состояние экрана текущей активной сессии (если есть). */ + private suspend fun pushScreenState() { + val connId = currentConn.get() ?: return + sendScreenState(connId) + } + + /** + * Слушаем системные `ACTION_SCREEN_ON/OFF`, чтобы держать телефон в курсе реального + * состояния экрана — это питает кнопку-тумблер «экран вкл/выкл» на экране «Пульт». + */ + private fun registerScreenReceiver() { + if (screenReceiver != null) return + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val on = intent?.action == Intent.ACTION_SCREEN_ON + Log.i(TAG, "Экран ${if (on) "включён" else "погашен"}") + lifecycleScope.launch { pushScreenState() } + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED) + screenReceiver = receiver + } + + private fun unregisterScreenReceiver() { + val r = screenReceiver ?: return + screenReceiver = null + runCatching { unregisterReceiver(r) } + } + private fun createNotificationChannel(): String { val channelId = "vm_service_channel" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -499,6 +563,7 @@ class VmService : LifecycleService() { audioServer = null audioSessions.clear() unregisterMdns() + unregisterScreenReceiver() server?.close() server = null instance = null diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index b73aa93..23241f8 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -28,6 +28,7 @@ import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.ScreenControl +import pw.binom.rayneovm.shared.ScreenState import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetText import pw.binom.rayneovm.shared.ShizukuStatus @@ -56,6 +57,10 @@ class VmPhoneController( private val _glassesShizuku = MutableStateFlow(null) val glassesShizuku: StateFlow = _glassesShizuku.asStateFlow() + /** Реальное состояние экрана очков (шлют очки); null — пока неизвестно. */ + private val _screenOn = MutableStateFlow(null) + val screenOn: StateFlow = _screenOn.asStateFlow() + private var currentTransport: StringTransport? = null private val audioManager by lazy { AudioPlaybackManager(context) } @@ -122,6 +127,7 @@ class VmPhoneController( onDisconnected = { currentTransport = null _glassesShizuku.value = null + _screenOn.value = null // Очки отвалились — аудио-поток всё равно мёртв, глушим трек. audioManager.stop() _state.value = ConnectionState.Disconnected @@ -159,6 +165,9 @@ class VmPhoneController( is ShizukuStatus -> { _glassesShizuku.value = msg } + is ScreenState -> { + _screenOn.value = msg.on + } is SetAudioMode -> { // Аудио режим — отдельный поток, не контролируется этим контроллером напрямую. // AudioCaptureService на очках стартует/останавливается. @@ -226,6 +235,8 @@ class VmPhoneController( suspend fun setScreen(sleep: Boolean): Boolean { if (_state.value !is ConnectionState.Connected) return false send(ScreenControl(sleep)) + // Оптимистично: точное состояние очки пришлют сами (ScreenState) — возможно с задержкой. + _screenOn.value = !sleep return true } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt index ff0dbed..20c7dab 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt @@ -20,10 +20,12 @@ import androidx.compose.material.icons.filled.FastRewind 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.PowerSettingsNew import androidx.compose.material3.Button import androidx.compose.material3.Icon 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 @@ -42,6 +44,7 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import pw.binom.rayneovm.phone.protocol.ConnectionState import pw.binom.rayneovm.phone.protocol.VmPhoneController import pw.binom.rayneovm.shared.VmDisplay @@ -71,6 +74,9 @@ fun RemoteScreen( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + // Реальное состояние экрана очков (шлют сами очки) — питает кнопку-тумблер ниже. + val screenOn by controller.screenOn.collectAsState() + val connected = controller.state.collectAsState().value is ConnectionState.Connected // Позиция курсора в координатах VirtualDisplay гостя. var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) } // Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть» @@ -203,6 +209,23 @@ fun RemoteScreen( } } + // Одна кнопка-тумблер: гасит/будит экран очков. Символ отражает текущее состояние + // (его шлют очки), поэтому нажатие всегда чередует вкл/выкл корректно. + Row(modifier = Modifier.fillMaxWidth()) { + RemoteButton( + icon = Icons.Default.PowerSettingsNew, + label = when (screenOn) { + true -> "Погасить экран очков" + false -> "Разбудить экран очков" + null -> "Экран очков: вкл/выкл" + }, + modifier = Modifier.weight(1f), + enabled = connected, + ) { + scope.launch { controller.setScreen(sleep = screenOn != false) } + } + } + if (showKeyboard) { KeyboardDialog( onDismiss = { showKeyboard = false }, diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index d2cb95a..8986933 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -196,6 +196,18 @@ data class AppLaunchedAck( val success: Boolean, ) : VmMessage +/** + * Состояние экрана очков: [on] = true — экран включён, false — погашен. + * + * Очки шлют его при подключении телефона и при каждом изменении (`ACTION_SCREEN_ON`/`OFF`), + * чтобы кнопка-тумблер на телефоне отражала реальное состояние, а не догадку. + */ +@Serializable +@SerialName("screen_state") +data class ScreenState( + val on: Boolean, +) : VmMessage + /** * Состояние Shizuku на очках. *