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

- 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.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<String?>(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