экран очков: единый тумблер на «Пульте» + очки шлют реальное состояние экрана
- 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:
@@ -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
|
||||
|
||||
@@ -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<ShizukuStatus?>(null)
|
||||
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 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 на очках.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user