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.GetAppList
|
||||||
import pw.binom.rayneovm.shared.AppEntry
|
import pw.binom.rayneovm.shared.AppEntry
|
||||||
import pw.binom.rayneovm.shared.AppListResponse
|
import pw.binom.rayneovm.shared.AppListResponse
|
||||||
|
import pw.binom.rayneovm.shared.CursorScroll
|
||||||
import pw.binom.rayneovm.shared.CursorClick
|
import pw.binom.rayneovm.shared.CursorClick
|
||||||
import pw.binom.rayneovm.shared.CursorMove
|
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() {
|
private fun startServer() {
|
||||||
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
|
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
|
||||||
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
@@ -94,10 +103,15 @@ class VmService : LifecycleService() {
|
|||||||
},
|
},
|
||||||
onConnected = { connId ->
|
onConnected = { connId ->
|
||||||
Log.i(TAG, "Phone connected: $connId")
|
Log.i(TAG, "Phone connected: $connId")
|
||||||
|
currentConn.set(connId)
|
||||||
},
|
},
|
||||||
onDisconnected = { connId ->
|
onDisconnected = { connId ->
|
||||||
Log.i(TAG, "Phone disconnected: $connId")
|
Log.i(TAG, "Phone disconnected: $connId")
|
||||||
|
if (currentConn.compareAndSet(connId, null)) {
|
||||||
controller.setState(GlassesScreenState.NoConnection)
|
controller.setState(GlassesScreenState.NoConnection)
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Ignoring disconnect of stale session $connId")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -129,6 +143,10 @@ class VmService : LifecycleService() {
|
|||||||
Log.d(TAG, "CursorClick")
|
Log.d(TAG, "CursorClick")
|
||||||
controller.clickCursor()
|
controller.clickCursor()
|
||||||
}
|
}
|
||||||
|
is CursorScroll -> {
|
||||||
|
Log.d(TAG, "CursorScroll: ${msg.dx},${msg.dy}")
|
||||||
|
controller.scrollCursor(msg.dx, msg.dy)
|
||||||
|
}
|
||||||
is VmKeyEvent -> {
|
is VmKeyEvent -> {
|
||||||
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
|
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
|
||||||
controller.sendKey(msg.keyCode, 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.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -41,6 +46,15 @@ class VmGlassesController {
|
|||||||
val cursor: StateFlow<CursorPosition?> = _cursor.asStateFlow()
|
val cursor: StateFlow<CursorPosition?> = _cursor.asStateFlow()
|
||||||
|
|
||||||
private var activeSource: VirtualDisplayFrameSource? = null
|
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
|
@Volatile private var appContext: Context? = null
|
||||||
|
|
||||||
/** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
|
/** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
|
||||||
@@ -87,7 +101,7 @@ class VmGlassesController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
runCatching { ShizukuVdBinder.stopKillGuard(src.shizukuBinder) }
|
runCatching { stopMercuryKillGuard(src.shizukuBinder) }
|
||||||
src.release()
|
src.release()
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "releaseFrameSource failed", t)
|
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, ...). */
|
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
|
||||||
fun sendKey(keyCode: Int, action: Int) {
|
fun sendKey(keyCode: Int, action: Int) {
|
||||||
val src = activeSource ?: return
|
val src = activeSource ?: return
|
||||||
@@ -180,13 +221,8 @@ class VmGlassesController {
|
|||||||
val launchIntent = pm.getLaunchIntentForPackage(packageName)
|
val launchIntent = pm.getLaunchIntentForPackage(packageName)
|
||||||
val className = launchIntent?.component?.className ?: ""
|
val className = launchIntent?.component?.className ?: ""
|
||||||
|
|
||||||
// Пока гость работает, держим «страж»: Settings.Secure.top_app = наш пакет.
|
// Пока гость работает — держим выключенным Mercury-«убийцу» (BackgroundAppManager).
|
||||||
// Mercury-лаунчер по первой же проверке в forceStopApp (pkgName == mCurrentTopPkg)
|
startMercuryKillGuard(src.shizukuBinder)
|
||||||
// отменяет убийство. Иначе нас/гостя force-стопит через ~2 c после смены top-app.
|
|
||||||
// Подробный разбор — в ShizukuVdBinder.startKillGuard.
|
|
||||||
if (!ShizukuVdBinder.startKillGuard(src.shizukuBinder, ctx2.packageName)) {
|
|
||||||
Log.w(TAG, "launchGuest: не удалось запустить Mercury kill guard")
|
|
||||||
}
|
|
||||||
|
|
||||||
val ok = ShizukuVdBinder.launchActivity(
|
val ok = ShizukuVdBinder.launchActivity(
|
||||||
binder = src.shizukuBinder,
|
binder = src.shizukuBinder,
|
||||||
@@ -209,6 +245,91 @@ class VmGlassesController {
|
|||||||
setState(GlassesScreenState.NoAppSelected)
|
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) {
|
fun setAppContext(context: Context) {
|
||||||
this.appContext = context.applicationContext
|
this.appContext = context.applicationContext
|
||||||
}
|
}
|
||||||
@@ -219,6 +340,24 @@ class VmGlassesController {
|
|||||||
private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
|
private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
|
||||||
private const val VIRTUAL_DENSITY = VmDisplay.DENSITY
|
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> {
|
val Local = staticCompositionLocalOf<VmGlassesController> {
|
||||||
error("VmGlassesController not provided")
|
error("VmGlassesController not provided")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.mercury.DiscoveredGlasses
|
import pw.binom.mercury.DiscoveredGlasses
|
||||||
import pw.binom.mercury.RayNeoMercury
|
import pw.binom.mercury.RayNeoMercury
|
||||||
|
import pw.binom.mercury.model.GeneralStatus
|
||||||
|
import pw.binom.mercury.model.MyWifiInfo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контроллер второго канала связи с очками — RayNeo Mercury BLE.
|
* Контроллер второго канала связи с очками — RayNeo Mercury BLE.
|
||||||
@@ -40,6 +43,21 @@ class MercuryController(private val context: Context) {
|
|||||||
|
|
||||||
private var lastScanResults: List<DiscoveredGlasses> = emptyList()
|
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. */
|
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
|
||||||
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
arrayOf(
|
arrayOf(
|
||||||
@@ -156,6 +174,7 @@ class MercuryController(private val context: Context) {
|
|||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
scope.launch { mercury.disconnect() }
|
scope.launch { mercury.disconnect() }
|
||||||
|
clearGlassesData()
|
||||||
_state.value = MercuryState.Disconnected()
|
_state.value = MercuryState.Disconnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +190,81 @@ class MercuryController(private val context: Context) {
|
|||||||
/** Доступ к raw event-flow библиотеки (debug/диагностика). */
|
/** Доступ к raw event-flow библиотеки (debug/диагностика). */
|
||||||
val events get() = mercury.events
|
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() {
|
private fun refreshPermissionState() {
|
||||||
_state.value = when {
|
_state.value = when {
|
||||||
!hasPermissions() -> MercuryState.PermissionsMissing
|
!hasPermissions() -> MercuryState.PermissionsMissing
|
||||||
@@ -205,12 +299,14 @@ class MercuryController(private val context: Context) {
|
|||||||
is RayNeoMercury.Event.Disconnected -> {
|
is RayNeoMercury.Event.Disconnected -> {
|
||||||
val cur = _state.value
|
val cur = _state.value
|
||||||
if (cur is MercuryState.Connected) {
|
if (cur is MercuryState.Connected) {
|
||||||
|
clearGlassesData()
|
||||||
_state.value = MercuryState.Disconnected(
|
_state.value = MercuryState.Disconnected(
|
||||||
message = "Связь потеряна: ${event.cause}",
|
message = "Связь потеряна: ${event.cause}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is RayNeoMercury.Event.GeneralStatusUpdate -> {
|
is RayNeoMercury.Event.GeneralStatusUpdate -> {
|
||||||
|
_generalStatus.value = event.status
|
||||||
val cur = _state.value
|
val cur = _state.value
|
||||||
if (cur is MercuryState.Connected) {
|
if (cur is MercuryState.Connected) {
|
||||||
_state.value = cur.copy(
|
_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
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +330,11 @@ class MercuryController(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "MercuryController"
|
||||||
|
|
||||||
|
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
|
||||||
|
private const val WIFI_SCAN_TIMEOUT_MS = 15_000L
|
||||||
|
|
||||||
val Local = staticCompositionLocalOf<MercuryController> {
|
val Local = staticCompositionLocalOf<MercuryController> {
|
||||||
error("MercuryController not provided")
|
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.AppLaunchedAck
|
||||||
import pw.binom.rayneovm.shared.CursorClick
|
import pw.binom.rayneovm.shared.CursorClick
|
||||||
import pw.binom.rayneovm.shared.CursorMove
|
import pw.binom.rayneovm.shared.CursorMove
|
||||||
|
import pw.binom.rayneovm.shared.CursorScroll
|
||||||
import pw.binom.rayneovm.shared.GetAppList
|
import pw.binom.rayneovm.shared.GetAppList
|
||||||
import pw.binom.rayneovm.shared.GlassesStatus
|
import pw.binom.rayneovm.shared.GlassesStatus
|
||||||
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
|
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
|
||||||
@@ -75,8 +76,9 @@ class VmPhoneController(
|
|||||||
_state.value = ConnectionState.Searching
|
_state.value = ConnectionState.Searching
|
||||||
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
|
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
|
||||||
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
|
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
|
||||||
// Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT).
|
// Порт 8080 и путь /vm (см. VmService.WS_PORT/WS_PATH). Путь нужен фоллбеку,
|
||||||
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080)
|
// чтобы отличить наш сервер от чужого на том же порту.
|
||||||
|
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080, fallbackPath = "/vm")
|
||||||
_state.value = ConnectionState.Connecting(service.host)
|
_state.value = ConnectionState.Connecting(service.host)
|
||||||
val url = "ws://${service.host}:${service.port}/vm"
|
val url = "ws://${service.host}:${service.port}/vm"
|
||||||
val transport = Transports.wifi(url, autoReconnect = false)
|
val transport = Transports.wifi(url, autoReconnect = false)
|
||||||
@@ -168,6 +170,9 @@ class VmPhoneController(
|
|||||||
/** Клик мыши в текущей позиции курсора на очках. */
|
/** Клик мыши в текущей позиции курсора на очках. */
|
||||||
suspend fun clickCursor() = send(CursorClick)
|
suspend fun clickCursor() = send(CursorClick)
|
||||||
|
|
||||||
|
/** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */
|
||||||
|
suspend fun scrollCursor(dx: Float, dy: Float) = send(CursorScroll(dx, dy))
|
||||||
|
|
||||||
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
|
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
|
||||||
suspend fun sendKey(keyCode: Int) {
|
suspend fun sendKey(keyCode: Int) {
|
||||||
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
|
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.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -19,19 +20,29 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
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.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.mercury.DiscoveredGlasses
|
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.MercuryController
|
||||||
import pw.binom.rayneovm.phone.mercury.MercuryState
|
import pw.binom.rayneovm.phone.mercury.MercuryState
|
||||||
import pw.binom.rayneovm.phone.protocol.ConnectionState
|
import pw.binom.rayneovm.phone.protocol.ConnectionState
|
||||||
@@ -58,6 +69,9 @@ fun GlassesStatusScreen(
|
|||||||
val connection by controller.state.collectAsState()
|
val connection by controller.state.collectAsState()
|
||||||
val glasses by controller.glassesStatus.collectAsState()
|
val glasses by controller.glassesStatus.collectAsState()
|
||||||
val mercury by mercuryController.state.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 scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val mercuryConnected = mercury is MercuryState.Connected
|
val mercuryConnected = mercury is MercuryState.Connected
|
||||||
@@ -89,6 +103,24 @@ fun GlassesStatusScreen(
|
|||||||
onDisconnect = { mercuryController.disconnect() },
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
LaunchGlassesAppButton(
|
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
|
@Composable
|
||||||
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
|
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
Button(
|
Button(
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package pw.binom.rayneovm.phone.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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))
|
.clip(RoundedCornerShape(20.dp))
|
||||||
.background(Color(0xFF141414))
|
.background(Color(0xFF141414))
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(onTap = { click() })
|
// Один палец — курсор; два пальца — скролл (как на тачпаде ноутбука).
|
||||||
}
|
awaitEachGesture {
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectDragGestures { change, drag ->
|
|
||||||
// Относительное движение: полный проход по тачпаду = весь экран гостя.
|
|
||||||
val gainX = VmDisplay.WIDTH / size.width.toFloat()
|
val gainX = VmDisplay.WIDTH / size.width.toFloat()
|
||||||
val gainY = VmDisplay.HEIGHT / size.height.toFloat()
|
val gainY = VmDisplay.HEIGHT / size.height.toFloat()
|
||||||
|
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 = Offset(
|
||||||
(cursor.x + drag.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
|
(cursor.x + delta.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
|
||||||
(cursor.y + drag.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
|
(cursor.y + delta.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
|
||||||
)
|
)
|
||||||
change.consume()
|
|
||||||
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Тачпад: ведите пальцем — курсор на очках.\nТап — клик по курсору.",
|
text = "Тачпад: один палец — курсор на очках, тап — клик.\nДва пальца — скролл.",
|
||||||
color = Color(0xFF666666),
|
color = Color(0xFF666666),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
textAlign = TextAlign.Center,
|
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")
|
@SerialName("cursor_click")
|
||||||
data object CursorClick : VmMessage
|
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
|
@Serializable
|
||||||
@SerialName("launch_app")
|
@SerialName("launch_app")
|
||||||
data class LaunchAppCommand(
|
data class LaunchAppCommand(
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ class VmProtocolTest {
|
|||||||
assertEquals(CursorClick, decoded)
|
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
|
@Test
|
||||||
fun `decodeAny for CursorMove uses type discriminator`() {
|
fun `decodeAny for CursorMove uses type discriminator`() {
|
||||||
val msg = VmProtocol.decodeAny("""{"type":"cursor_move","x":10.5,"y":20.25}""")
|
val msg = VmProtocol.decodeAny("""{"type":"cursor_move","x":10.5,"y":20.25}""")
|
||||||
|
|||||||
Reference in New Issue
Block a user