From c69e8a2e8030d6dcedc988b546f9b4a68fabb5f0 Mon Sep 17 00:00:00 2001 From: subochev Date: Fri, 25 Sep 2026 19:49:25 +0300 Subject: [PATCH] glasses: hold Mercury kill switch on hidden VirtualDisplay; phone: brightness/WiFi panel, two-finger touchpad scroll --- .../pw/binom/rayneovm/glasses/VmService.kt | 20 +- .../glasses/protocol/VmGlassesController.kt | 155 +++++++++++++- .../phone/mercury/MercuryController.kt | 105 ++++++++++ .../phone/protocol/VmPhoneController.kt | 9 +- .../rayneovm/phone/ui/GlassesStatusScreen.kt | 189 ++++++++++++++++++ .../binom/rayneovm/phone/ui/RemoteScreen.kt | 70 +++++-- .../pw/binom/rayneovm/shared/VmMessage.kt | 14 ++ .../binom/rayneovm/shared/VmProtocolTest.kt | 8 + 8 files changed, 544 insertions(+), 26 deletions(-) diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 4450fa2..b2c3929 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -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(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) diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt index 7b9ef8e..314b018 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt @@ -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 = _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 { error("VmGlassesController not provided") } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt index fa19820..81e1cc5 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt @@ -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 = emptyList() + private val _generalStatus = MutableStateFlow(null) + + /** Последний известный статус очков: яркость, WiFi, экран, батарея. */ + val generalStatus: StateFlow = _generalStatus.asStateFlow() + + private val _wifiNetworks = MutableStateFlow>(emptyList()) + + /** Сети, найденные последним сканом WiFi на очках. */ + val wifiNetworks: StateFlow> = _wifiNetworks.asStateFlow() + + private val _wifiScanning = MutableStateFlow(false) + + /** Идёт ли сейчас скан WiFi-сетей очков. */ + val wifiScanning: StateFlow = _wifiScanning.asStateFlow() + /** Разрешения, которые нужно запросить для работы с BLE на этом API. */ val requiredPermissions: Array = 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 { error("MercuryController not provided") } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index bf40c94..174eecb 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -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)) diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt index 3b86eb0..4bd787e 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt @@ -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(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, + scanning: Boolean, + enabled: Boolean, + onToggle: (Boolean) -> Unit, + onScan: () -> Unit, + onConnect: (String, String?) -> Unit, +) { + var pendingNetwork by remember { mutableStateOf(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( diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt index b846c5a..955a677 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/RemoteScreen.kt @@ -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 } diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index 83c6406..5c801bd 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -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( diff --git a/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt b/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt index 3f30743..64a91fe 100644 --- a/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt +++ b/shared/src/test/kotlin/pw/binom/rayneovm/shared/VmProtocolTest.kt @@ -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(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}""")