glasses: hold Mercury kill switch on hidden VirtualDisplay; phone: brightness/WiFi panel, two-finger touchpad scroll
This commit is contained in:
@@ -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)
|
||||
|
||||
+147
-8
@@ -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}""")
|
||||
|
||||
Reference in New Issue
Block a user