экран очков: единый тумблер на «Пульте» + очки шлют реальное состояние экрана

- shared: новое сообщение ScreenState(on) от очков телефону
- glasses: слушаем ACTION_SCREEN_ON/OFF (PowerManager.isInteractive), шлём состояние
  при подключении телефона и на каждое изменение; после ScreenControl — актуализация
- phone: VmPhoneController.screenOn (StateFlow<Boolean?>), оптимистичное обновление в setScreen
- phone: на экране «Пульт» одна кнопка-тумблер «Погасить/Разбудить экран очков»,
  подпись отражает реальное состояние, disabled без Канала 1
This commit is contained in:
2026-10-01 11:26:37 +03:00
parent de6c648c35
commit 4743427756
4 changed files with 111 additions and 0 deletions
@@ -4,14 +4,17 @@ import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.media.AudioManager import android.media.AudioManager
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService 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.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.audio.AudioConfig import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram import pw.binom.rayneovm.shared.audio.AudioDatagram
@@ -118,6 +122,7 @@ class VmService : LifecycleService() {
startAudioServer() startAudioServer()
startAudioPump() startAudioPump()
registerMdns() registerMdns()
registerScreenReceiver()
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся // Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
// или нам выдали permission) — чтобы телефон не опрашивал вручную. // или нам выдали permission) — чтобы телефон не опрашивал вручную.
lifecycleScope.launch { lifecycleScope.launch {
@@ -169,6 +174,9 @@ class VmService : LifecycleService() {
*/ */
private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null) private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null)
/** Ресивер `ACTION_SCREEN_ON/OFF`: сообщаем телефону реальное состояние экрана очков. */
private var screenReceiver: BroadcastReceiver? = null
private fun startServer() { private fun startServer() {
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH) server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
@@ -184,6 +192,8 @@ class VmService : LifecycleService() {
controller.onPhoneConnected() controller.onPhoneConnected()
// И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа). // И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа).
lifecycleScope.launch { sendShizukuStatus(connId) } lifecycleScope.launch { sendShizukuStatus(connId) }
// ...и реальное состояние экрана (для кнопки-тумблера на телефоне).
lifecycleScope.launch { sendScreenState(connId) }
}, },
onDisconnected = { connId -> onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId") Log.i(TAG, "Phone disconnected: $connId")
@@ -238,6 +248,11 @@ class VmService : LifecycleService() {
is ScreenControl -> { is ScreenControl -> {
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}") Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
controller.setScreen(msg.sleep) controller.setScreen(msg.sleep)
// Экран переключается чуть позже инжекта — сообщаем телефону актуальное состояние.
lifecycleScope.launch {
kotlinx.coroutines.delay(600)
pushScreenState()
}
} }
is SetText -> { is SetText -> {
Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}") Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}")
@@ -461,6 +476,55 @@ class VmService : LifecycleService() {
sendShizukuStatus(connId) 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 { private fun createNotificationChannel(): String {
val channelId = "vm_service_channel" val channelId = "vm_service_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -499,6 +563,7 @@ class VmService : LifecycleService() {
audioServer = null audioServer = null
audioSessions.clear() audioSessions.clear()
unregisterMdns() unregisterMdns()
unregisterScreenReceiver()
server?.close() server?.close()
server = null server = null
instance = null instance = null
@@ -28,6 +28,7 @@ import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.SetText import pw.binom.rayneovm.shared.SetText
import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.ShizukuStatus
@@ -56,6 +57,10 @@ class VmPhoneController(
private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null) private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null)
val glassesShizuku: StateFlow<ShizukuStatus?> = _glassesShizuku.asStateFlow() val glassesShizuku: StateFlow<ShizukuStatus?> = _glassesShizuku.asStateFlow()
/** Реальное состояние экрана очков (шлют очки); null — пока неизвестно. */
private val _screenOn = MutableStateFlow<Boolean?>(null)
val screenOn: StateFlow<Boolean?> = _screenOn.asStateFlow()
private var currentTransport: StringTransport? = null private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) } private val audioManager by lazy { AudioPlaybackManager(context) }
@@ -122,6 +127,7 @@ class VmPhoneController(
onDisconnected = { onDisconnected = {
currentTransport = null currentTransport = null
_glassesShizuku.value = null _glassesShizuku.value = null
_screenOn.value = null
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек. // Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
audioManager.stop() audioManager.stop()
_state.value = ConnectionState.Disconnected _state.value = ConnectionState.Disconnected
@@ -159,6 +165,9 @@ class VmPhoneController(
is ShizukuStatus -> { is ShizukuStatus -> {
_glassesShizuku.value = msg _glassesShizuku.value = msg
} }
is ScreenState -> {
_screenOn.value = msg.on
}
is SetAudioMode -> { is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую. // Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается. // AudioCaptureService на очках стартует/останавливается.
@@ -226,6 +235,8 @@ class VmPhoneController(
suspend fun setScreen(sleep: Boolean): Boolean { suspend fun setScreen(sleep: Boolean): Boolean {
if (_state.value !is ConnectionState.Connected) return false if (_state.value !is ConnectionState.Connected) return false
send(ScreenControl(sleep)) send(ScreenControl(sleep))
// Оптимистично: точное состояние очки пришлют сами (ScreenState) — возможно с задержкой.
_screenOn.value = !sleep
return true return true
} }
@@ -20,10 +20,12 @@ import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Keyboard import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -42,6 +44,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.VmDisplay import pw.binom.rayneovm.shared.VmDisplay
@@ -71,6 +74,9 @@ fun RemoteScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Реальное состояние экрана очков (шлют сами очки) — питает кнопку-тумблер ниже.
val screenOn by controller.screenOn.collectAsState()
val connected = controller.state.collectAsState().value is ConnectionState.Connected
// Позиция курсора в координатах VirtualDisplay гостя. // Позиция курсора в координатах VirtualDisplay гостя.
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) } 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) { if (showKeyboard) {
KeyboardDialog( KeyboardDialog(
onDismiss = { showKeyboard = false }, onDismiss = { showKeyboard = false },
@@ -196,6 +196,18 @@ data class AppLaunchedAck(
val success: Boolean, val success: Boolean,
) : VmMessage ) : VmMessage
/**
* Состояние экрана очков: [on] = true — экран включён, false — погашен.
*
* Очки шлют его при подключении телефона и при каждом изменении (`ACTION_SCREEN_ON`/`OFF`),
* чтобы кнопка-тумблер на телефоне отражала реальное состояние, а не догадку.
*/
@Serializable
@SerialName("screen_state")
data class ScreenState(
val on: Boolean,
) : VmMessage
/** /**
* Состояние Shizuku на очках. * Состояние Shizuku на очках.
* *