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