glasses: hold Mercury kill switch on hidden VirtualDisplay; phone: brightness/WiFi panel, two-finger touchpad scroll

This commit is contained in:
2026-09-25 19:49:25 +03:00
parent 3fce3eb3ca
commit c69e8a2e80
8 changed files with 544 additions and 26 deletions
@@ -32,6 +32,7 @@ import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
@@ -84,6 +85,14 @@ class VmService : LifecycleService() {
}
}
/**
* Текущая активная сессия телефона.
*
* Нужна, чтобы отключение «чужой»/устаревшей сессии (например, пробного WebSocket-хендшейка
* из subnet-фоллбека при поиске очков) не сбрасывало экран в NoConnection, пока жива настоящая.
*/
private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null)
private fun startServer() {
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
@@ -94,10 +103,15 @@ class VmService : LifecycleService() {
},
onConnected = { connId ->
Log.i(TAG, "Phone connected: $connId")
currentConn.set(connId)
},
onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId")
controller.setState(GlassesScreenState.NoConnection)
if (currentConn.compareAndSet(connId, null)) {
controller.setState(GlassesScreenState.NoConnection)
} else {
Log.i(TAG, "Ignoring disconnect of stale session $connId")
}
},
)
} catch (t: Throwable) {
@@ -129,6 +143,10 @@ class VmService : LifecycleService() {
Log.d(TAG, "CursorClick")
controller.clickCursor()
}
is CursorScroll -> {
Log.d(TAG, "CursorScroll: ${msg.dx},${msg.dy}")
controller.scrollCursor(msg.dx, msg.dy)
}
is VmKeyEvent -> {
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
controller.sendKey(msg.keyCode, msg.action)
@@ -5,14 +5,19 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.SurfaceTexture
import android.os.IBinder
import android.util.Log
import android.view.Surface
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -41,6 +46,15 @@ class VmGlassesController {
val cursor: StateFlow<CursorPosition?> = _cursor.asStateFlow()
private var activeSource: VirtualDisplayFrameSource? = null
/** Служебный дисплей Mercury-«стража» (см. [startMercuryKillGuard]); 0 — не создан. */
private var guardDisplayId = 0
/** Surface служебного дисплея: без валидной Surface дисплей считается OFF и активность не стартует. */
private var guardSurfaceTexture: SurfaceTexture? = null
/** Watchdog: переподнимает активность стража, если она завершилась сама. */
private var guardWatchdog: Job? = null
@Volatile private var appContext: Context? = null
/** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
@@ -87,7 +101,7 @@ class VmGlassesController {
return
}
try {
runCatching { ShizukuVdBinder.stopKillGuard(src.shizukuBinder) }
runCatching { stopMercuryKillGuard(src.shizukuBinder) }
src.release()
} catch (t: Throwable) {
Log.w(TAG, "releaseFrameSource failed", t)
@@ -130,6 +144,33 @@ class VmGlassesController {
}
}
/**
* Скролл в текущей позиции курсора: [dx]/[dy] — смещение пальцев в пикселях координат
* гостя (два пальца по тачпаду). Инжектим колесо мыши (`ACTION_SCROLL`) в точку курсора.
*
* Гость умножает axis-значение на `ViewConfiguration.scaledVerticalScrollFactor`
* (≈ [SCROLL_FACTOR]px при density 160), поэтому делим на него: смещение становится
* 1:1 с движением пальца.
*/
fun scrollCursor(dx: Float, dy: Float) {
if (dx == 0f && dy == 0f) return
val src = activeSource ?: return
val pos = _cursor.value ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
testScope.launch {
val ok = ShizukuVdBinder.scroll(
binder = binder,
displayId = displayId,
x = pos.x,
y = pos.y,
hScroll = dx / SCROLL_FACTOR,
vScroll = dy / SCROLL_FACTOR,
)
if (!ok) Log.w(TAG, "scrollCursor: scroll failed (displayId=$displayId)")
}
}
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
fun sendKey(keyCode: Int, action: Int) {
val src = activeSource ?: return
@@ -180,13 +221,8 @@ class VmGlassesController {
val launchIntent = pm.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: ""
// Пока гость работает, держим «страж»: Settings.Secure.top_app = наш пакет.
// Mercury-лаунчер по первой же проверке в forceStopApp (pkgName == mCurrentTopPkg)
// отменяет убийство. Иначе нас/гостя force-стопит через ~2 c после смены top-app.
// Подробный разбор — в ShizukuVdBinder.startKillGuard.
if (!ShizukuVdBinder.startKillGuard(src.shizukuBinder, ctx2.packageName)) {
Log.w(TAG, "launchGuest: не удалось запустить Mercury kill guard")
}
// Пока гость работает — держим выключенным Mercury-«убийцу» (BackgroundAppManager).
startMercuryKillGuard(src.shizukuBinder)
val ok = ShizukuVdBinder.launchActivity(
binder = src.shizukuBinder,
@@ -209,6 +245,91 @@ class VmGlassesController {
setState(GlassesScreenState.NoAppSelected)
}
/**
* Выключает Mercury-«убийцу» фоновых приложений (`BackgroundAppManager`) на время работы гостя.
*
* У RayNeo политика «одно приложение на переднем плане»: на каждой смене `Secure.top_app`
* лаунчер force-стопит все пакеты из накопительной очереди предыдущих топов. Гость на
* VirtualDisplay тоже считается топом, поэтому лаунчер убивал то наше приложение, то гостя
* (снаружи выглядело как «клик работает ровно один раз»).
*
* Легальный выключатель — вендорский `BackgroundAppManager.enableStopBackgroundKill(true)`.
* Его ставит `NetConfigActivity.onStart` и снимает в `onStop`, поэтому держим эту активность
* запущенной на отдельном невидимом VirtualDisplay: там её никто не перекрывает, флаг
* остаётся true, и `forceStopApp` отменяется на проверке `tmpStopBackgroundKill`.
*
* Почему не через `top_app` и не через провайдер: `Settings.Secure.top_app` лаунчер сам
* переписывает по реальному топу, а `ContentProvider.getCallingPackage()` на API 31+
* сверяет пакет с реальным uid вызывающего — shell (uid 2000) не может представиться
* `com.leiniao.camera`.
*/
private suspend fun startMercuryKillGuard(binder: IBinder) {
if (guardDisplayId > 0) return
val surfaceTexture = try {
// Дисплей с невалідной Surface считается OFF, и активность на нём не стартует.
SurfaceTexture(false).also { it.setDefaultBufferSize(GUARD_WIDTH, GUARD_HEIGHT) }
} catch (t: Throwable) {
Log.w(TAG, "startMercuryKillGuard: SurfaceTexture failed", t)
return
}
val displayId = try {
ShizukuVdBinder.createDisplay(
binder = binder,
name = GUARD_DISPLAY_NAME,
width = GUARD_WIDTH,
height = GUARD_HEIGHT,
density = VIRTUAL_DENSITY,
surface = Surface(surfaceTexture),
)
} catch (t: Throwable) {
Log.w(TAG, "startMercuryKillGuard: createDisplay failed", t)
surfaceTexture.release()
return
}
if (displayId <= 0) {
Log.w(TAG, "startMercuryKillGuard: createDisplay вернул displayId=$displayId")
surfaceTexture.release()
return
}
guardDisplayId = displayId
guardSurfaceTexture = surfaceTexture
launchGuardActivity(binder, displayId)
// Даём активности дойти до onStart: именно там включается флаг. Иначе первая же
// смена top_app (запуск гостя) успеет попасть в forceStopApp до включения флага.
delay(GUARD_WARMUP_MS)
guardWatchdog = testScope.launch {
while (isActive) {
delay(GUARD_RELAUNCH_MS)
launchGuardActivity(binder, displayId)
}
}
Log.i(TAG, "startMercuryKillGuard: страж поднят на displayId=$displayId")
}
private fun launchGuardActivity(binder: IBinder, displayId: Int) {
runCatching {
ShizukuVdBinder.launchActivity(
binder = binder,
packageName = GUARD_PACKAGE,
className = GUARD_ACTIVITY,
displayId = displayId,
)
}.onFailure { Log.w(TAG, "launchGuardActivity failed (displayId=$displayId)", it) }
}
/** Снимает стража: останавливает watchdog и освобождает служебный дисплей. */
private fun stopMercuryKillGuard(binder: IBinder) {
guardWatchdog?.cancel()
guardWatchdog = null
val displayId = guardDisplayId
guardDisplayId = 0
guardSurfaceTexture?.release()
guardSurfaceTexture = null
if (displayId > 0) {
runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
}
}
fun setAppContext(context: Context) {
this.appContext = context.applicationContext
}
@@ -219,6 +340,24 @@ class VmGlassesController {
private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
private const val VIRTUAL_DENSITY = VmDisplay.DENSITY
/** `ViewConfiguration.scaledVerticalScrollFactor` при density 160: гость умножает axis на это. */
private const val SCROLL_FACTOR = 64f
/** Активность RayNeo-лаунчера, чей `onStart` включает вендорский `tmpStopBackgroundKill`. */
private const val GUARD_PACKAGE = "com.ffalconxr.mercury.launcher"
private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
private const val GUARD_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240
/** Как часто перепроверяем, что активность стража жива. */
private const val GUARD_RELAUNCH_MS = 5_000L
/** Пауза после старта стража: даём `onStart` выставить флаг до запуска гостя. */
private const val GUARD_WARMUP_MS = 1_500L
val Local = staticCompositionLocalOf<VmGlassesController> {
error("VmGlassesController not provided")
}
@@ -12,12 +12,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury
import pw.binom.mercury.model.GeneralStatus
import pw.binom.mercury.model.MyWifiInfo
/**
* Контроллер второго канала связи с очками — RayNeo Mercury BLE.
@@ -40,6 +43,21 @@ class MercuryController(private val context: Context) {
private var lastScanResults: List<DiscoveredGlasses> = emptyList()
private val _generalStatus = MutableStateFlow<GeneralStatus?>(null)
/** Последний известный статус очков: яркость, WiFi, экран, батарея. */
val generalStatus: StateFlow<GeneralStatus?> = _generalStatus.asStateFlow()
private val _wifiNetworks = MutableStateFlow<List<MyWifiInfo>>(emptyList())
/** Сети, найденные последним сканом WiFi на очках. */
val wifiNetworks: StateFlow<List<MyWifiInfo>> = _wifiNetworks.asStateFlow()
private val _wifiScanning = MutableStateFlow(false)
/** Идёт ли сейчас скан WiFi-сетей очков. */
val wifiScanning: StateFlow<Boolean> = _wifiScanning.asStateFlow()
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
@@ -156,6 +174,7 @@ class MercuryController(private val context: Context) {
fun disconnect() {
scope.launch { mercury.disconnect() }
clearGlassesData()
_state.value = MercuryState.Disconnected()
}
@@ -171,6 +190,81 @@ class MercuryController(private val context: Context) {
/** Доступ к raw event-flow библиотеки (debug/диагностика). */
val events get() = mercury.events
// ------------------------------------------------------------------ экран / яркость / wifi
/** Яркость экрана очков, 0..100. */
fun setBrightness(value: Int) = launchMercury("setBrightness") {
mercury.setBrightness(value)
}
/** Авто-яркость экрана очков. */
fun setAutoBrightness(enable: Boolean) = launchMercury("setAutoBrightness") {
mercury.setAutoBrightness(enable)
}
/**
* Погасить (on=false, deepSuspend) или разбудить (on=true) экран очков.
*
* Перед засыпанием включаем heartbeat: без него очки роняют BLE-связь
* примерно через 3с после выключения экрана (см. RayNeoMercury.enableHeartbeat).
*/
fun setScreenOn(on: Boolean) = launchMercury("setScreenOn($on)") {
if (!on) mercury.enableHeartbeat()
mercury.setDeepSuspend(!on)
}
/** Включить/выключить WiFi-радио очков. */
fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") {
mercury.wifiToggle(enable)
}
/** Просканировать WiFi-сети очками; результат придёт в [wifiNetworks]. */
fun scanWifi() {
_wifiNetworks.value = emptyList()
_wifiScanning.value = true
scope.launch {
val ok = try {
mercury.wifiScan()
} catch (t: Throwable) {
android.util.Log.w(TAG, "wifiScan failed: ${t.message}")
false
}
if (!ok) _wifiScanning.value = false
}
// Очки не всегда присылают ответ — снимаем «сканируем…» по таймауту.
scope.launch {
delay(WIFI_SCAN_TIMEOUT_MS)
_wifiScanning.value = false
}
}
/** Подключить очки к WiFi-сети ([password] = null для открытой). */
fun connectWifi(ssid: String, password: String?) = launchMercury("connectWifi($ssid)") {
mercury.wifiConnect(ssid, password)
}
/** Забыть сохранённую сеть на очках. */
fun forgetWifi(ssid: String) = launchMercury("forgetWifi($ssid)") {
mercury.wifiRemove(ssid)
}
private fun launchMercury(action: String, block: suspend () -> Unit) {
scope.launch {
try {
block()
} catch (t: Throwable) {
android.util.Log.w(TAG, "$action failed: ${t.message}")
}
}
}
/** Сбрасывает данные, которые живут только внутри BLE-сессии. */
private fun clearGlassesData() {
_generalStatus.value = null
_wifiNetworks.value = emptyList()
_wifiScanning.value = false
}
private fun refreshPermissionState() {
_state.value = when {
!hasPermissions() -> MercuryState.PermissionsMissing
@@ -205,12 +299,14 @@ class MercuryController(private val context: Context) {
is RayNeoMercury.Event.Disconnected -> {
val cur = _state.value
if (cur is MercuryState.Connected) {
clearGlassesData()
_state.value = MercuryState.Disconnected(
message = "Связь потеряна: ${event.cause}",
)
}
}
is RayNeoMercury.Event.GeneralStatusUpdate -> {
_generalStatus.value = event.status
val cur = _state.value
if (cur is MercuryState.Connected) {
_state.value = cur.copy(
@@ -218,6 +314,10 @@ class MercuryController(private val context: Context) {
)
}
}
is RayNeoMercury.Event.WifiScanResult -> {
_wifiNetworks.value = event.networks
_wifiScanning.value = false
}
else -> Unit
}
}
@@ -230,6 +330,11 @@ class MercuryController(private val context: Context) {
}
companion object {
private const val TAG = "MercuryController"
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
private const val WIFI_SCAN_TIMEOUT_MS = 15_000L
val Local = staticCompositionLocalOf<MercuryController> {
error("MercuryController not provided")
}
@@ -20,6 +20,7 @@ import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
@@ -75,8 +76,9 @@ class VmPhoneController(
_state.value = ConnectionState.Searching
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
// Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT).
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080)
// Порт 8080 и путь /vm (см. VmService.WS_PORT/WS_PATH). Путь нужен фоллбеку,
// чтобы отличить наш сервер от чужого на том же порту.
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080, fallbackPath = "/vm")
_state.value = ConnectionState.Connecting(service.host)
val url = "ws://${service.host}:${service.port}/vm"
val transport = Transports.wifi(url, autoReconnect = false)
@@ -168,6 +170,9 @@ class VmPhoneController(
/** Клик мыши в текущей позиции курсора на очках. */
suspend fun clickCursor() = send(CursorClick)
/** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */
suspend fun scrollCursor(dx: Float, dy: Float) = send(CursorScroll(dx, dy))
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
suspend fun sendKey(keyCode: Int) {
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -19,19 +20,29 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.model.GeneralStatus
import pw.binom.mercury.model.MyWifiInfo
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryState
import pw.binom.rayneovm.phone.protocol.ConnectionState
@@ -58,6 +69,9 @@ fun GlassesStatusScreen(
val connection by controller.state.collectAsState()
val glasses by controller.glassesStatus.collectAsState()
val mercury by mercuryController.state.collectAsState()
val generalStatus by mercuryController.generalStatus.collectAsState()
val wifiNetworks by mercuryController.wifiNetworks.collectAsState()
val wifiScanning by mercuryController.wifiScanning.collectAsState()
val scope = rememberCoroutineScope()
val mercuryConnected = mercury is MercuryState.Connected
@@ -89,6 +103,24 @@ fun GlassesStatusScreen(
onDisconnect = { mercuryController.disconnect() },
)
ScreenAndBrightnessSection(
status = generalStatus,
enabled = mercuryConnected,
onBrightness = { mercuryController.setBrightness(it) },
onAutoBrightness = { mercuryController.setAutoBrightness(it) },
onScreenOn = { mercuryController.setScreenOn(it) },
)
GlassesWifiSection(
status = generalStatus,
networks = wifiNetworks,
scanning = wifiScanning,
enabled = mercuryConnected,
onToggle = { mercuryController.setWifiEnabled(it) },
onScan = { mercuryController.scanWifi() },
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
)
Spacer(modifier = Modifier.height(8.dp))
LaunchGlassesAppButton(
@@ -185,6 +217,163 @@ private fun Channel2MercurySection(
}
}
@Composable
private fun ScreenAndBrightnessSection(
status: GeneralStatus?,
enabled: Boolean,
onBrightness: (Int) -> Unit,
onAutoBrightness: (Boolean) -> Unit,
onScreenOn: (Boolean) -> Unit,
) {
SectionCard(title = "Экран и яркость") {
if (!enabled) {
Hint("Нужен Канал 2 (Mercury BLE).")
} else {
// Пока палец на слайдере — показываем локальное значение,
// а в очки отправляем один раз, на отпускании (BLE не любит спам).
var drag by remember { mutableStateOf<Float?>(null) }
val brightness = drag ?: (status?.brightness ?: 0).toFloat()
StatusRow("Яркость", "${brightness.toInt()}%")
Slider(
value = brightness,
onValueChange = { drag = it },
onValueChangeFinished = {
drag?.let { onBrightness(it.toInt()) }
drag = null
},
valueRange = 0f..100f,
)
ToggleRow(
label = "Авто-яркость",
checked = status?.automaticBrightness == true,
onCheckedChange = onAutoBrightness,
)
val sleeping = (status?.deepSuspend ?: 0) == 1
OutlinedButton(onClick = { onScreenOn(sleeping) }) {
Text(if (sleeping) "Разбудить экран" else "Погасить экран")
}
}
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,
networks: List<MyWifiInfo>,
scanning: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
onScan: () -> Unit,
onConnect: (String, String?) -> Unit,
) {
var pendingNetwork by remember { mutableStateOf<MyWifiInfo?>(null) }
SectionCard(title = "WiFi очков") {
if (!enabled) {
Hint("Нужен Канал 2 (Mercury BLE).")
} else {
ToggleRow(
label = "WiFi включён",
checked = status?.wifiEnabled == true,
onCheckedChange = onToggle,
)
StatusRow("Сеть", status?.wifiInfo?.ssid ?: "не подключено")
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(onClick = onScan, enabled = !scanning) {
Text(if (scanning) "Сканируем…" else "Найти сети")
}
if (networks.isNotEmpty()) {
Text("Найденные сети:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
networks.forEach { network ->
NetworkRow(
network = network,
onClick = {
if (network.needsPassword()) {
pendingNetwork = network
} else {
onConnect(network.ssid, null)
}
},
)
}
}
}
}
pendingNetwork?.let { network ->
WifiPasswordDialog(
ssid = network.ssid,
onDismiss = { pendingNetwork = null },
onConfirm = { password ->
onConnect(network.ssid, password)
pendingNetwork = null
},
)
}
}
@Composable
private fun NetworkRow(network: MyWifiInfo, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (network.saved) "${network.ssid} (сохранена)" else network.ssid,
color = Color.White,
fontSize = 14.sp,
)
if (network.needsPassword()) {
Text("нужен пароль", color = Color(0xFFB0B0B0), fontSize = 11.sp)
}
}
Button(onClick = onClick) { Text("Подключить") }
}
}
@Composable
private fun WifiPasswordDialog(ssid: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Пароль WiFi") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(ssid, color = Color(0xFFB0B0B0), fontSize = 13.sp)
OutlinedTextField(
value = password,
onValueChange = { password = it },
singleLine = true,
label = { Text("Пароль") },
visualTransformation = PasswordVisualTransformation(),
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(password) }) { Text("Подключить") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
},
)
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, color = Color(0xFFB0B0B0), fontSize = 14.sp)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
Button(
@@ -2,8 +2,8 @@ package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -48,8 +48,9 @@ private const val KEYCODE_MEDIA_PAUSE = 127
/**
* Пульт управления гостевым приложением на очках:
* - тачпад-мышь: движение пальца → курсор (отыгрывается на очках в обоих глазах),
* - тачпад-мышь: движение одного пальца → курсор (отыгрывается на очках в обоих глазах),
* тап по тачпаду → клик в текущей позиции курсора;
* - два пальца по тачпаду → скролл (вверх/вниз/вправо/влево) под курсором;
* - кнопки «Назад», «Плей», «Пауза»;
* - «Клавиатура» — заглушка (сделаем позже).
*/
@@ -77,25 +78,61 @@ fun RemoteScreen(
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF141414))
.pointerInput(Unit) {
detectTapGestures(onTap = { click() })
}
.pointerInput(Unit) {
detectDragGestures { change, drag ->
// Относительное движение: полный проход по тачпаду = весь экран гостя.
// Один палец — курсор; два пальца — скролл (как на тачпаде ноутбука).
awaitEachGesture {
val gainX = VmDisplay.WIDTH / size.width.toFloat()
val gainY = VmDisplay.HEIGHT / size.height.toFloat()
cursor = Offset(
(cursor.x + drag.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
(cursor.y + drag.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
change.consume()
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
var mode = PadMode.UNDECIDED
var lastCentroid = Offset.Zero
var travel = 0f
awaitFirstDown(requireUnconsumed = false)
while (true) {
val event = awaitPointerEvent()
val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) break
val centroid = pressed.fold(Offset.Zero) { acc, c -> acc + c.position } /
pressed.size.toFloat()
// Второй палец появился (или жест начался сразу с двух) — скроллим.
if (mode == PadMode.UNDECIDED || (mode == PadMode.CURSOR && pressed.size >= 2)) {
mode = if (pressed.size >= 2) PadMode.SCROLL else PadMode.CURSOR
lastCentroid = centroid
continue
}
val delta = centroid - lastCentroid
lastCentroid = centroid
if (delta.getDistance() <= 0f) continue
travel += delta.getDistance()
when (mode) {
PadMode.CURSOR -> {
// Относительное движение: полный проход по тачпаду = весь экран гостя.
cursor = Offset(
(cursor.x + delta.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
(cursor.y + delta.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
}
PadMode.SCROLL -> {
// Смещение пальцев в пикселях гостя; очки инжектят колесо.
scope.launch {
controller.scrollCursor(delta.x * gainX, delta.y * gainY)
}
}
PadMode.UNDECIDED -> Unit
}
event.changes.forEach { it.consume() }
}
// Отпустили, не сдвинув палец, — это тап (клик по курсору).
if (mode != PadMode.SCROLL && travel < viewConfiguration.touchSlop) click()
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = "Тачпад: ведите пальцем — курсор на очках.\nТап — клик по курсору.",
text = "Тачпад: один палец — курсор на очках, тап — клик.\nДва пальца — скролл.",
color = Color(0xFF666666),
fontSize = 13.sp,
textAlign = TextAlign.Center,
@@ -147,3 +184,6 @@ private fun RemoteButton(
}
}
}
/** Режим жеста на тачпаде — определяется по количеству пальцев. */
private enum class PadMode { UNDECIDED, CURSOR, SCROLL }
@@ -79,6 +79,20 @@ data class CursorMove(
@SerialName("cursor_click")
data object CursorClick : VmMessage
/**
* Скролл в текущей позиции курсора (два пальца по тачпаду).
*
* [dx]/[dy] — смещение пальцев в пикселях координат VirtualDisplay гостя. Очки превращают
* его в колесо мыши (`MotionEvent.ACTION_SCROLL` + `AXIS_HSCROLL`/`AXIS_VSCROLL`) в точке
* [CursorMove], поэтому скроллится то, что под курсором.
*/
@Serializable
@SerialName("cursor_scroll")
data class CursorScroll(
val dx: Float,
val dy: Float,
) : VmMessage
@Serializable
@SerialName("launch_app")
data class LaunchAppCommand(
@@ -60,6 +60,14 @@ class VmProtocolTest {
assertEquals(CursorClick, decoded)
}
@Test
fun `CursorScroll roundtrip`() {
val msg = CursorScroll(dx = -3.5f, dy = 12.25f)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<CursorScroll>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `decodeAny for CursorMove uses type discriminator`() {
val msg = VmProtocol.decodeAny("""{"type":"cursor_move","x":10.5,"y":20.25}""")