phone: third «Пульт» tab with on-glasses cursor; atomic click injection
- shared: VmDisplay (guest 640x480 coordinate space), CursorMove and CursorClick messages (CursorClick replaces the DOWN/UP pair — see below). - glasses: cursor state + Canvas overlay drawn in both eyes, clickCursor injects one atomic tap at the cursor, sendKey for Back/Play/Pause. - phone: RemoteScreen — touchpad (relative drag moves the cursor, tap clicks), «Назад»/«Плей»/«Пауза» buttons, «Клавиатура» stub (TODO). - The old CursorButton(DOWN)/CursorButton(UP) pair was removed: the two messages were injected concurrently and could arrive out of order, breaking the guest's touch stream so clicks worked only once. - tests: CursorMove/CursorClick roundtrips.
This commit is contained in:
@@ -32,6 +32,8 @@ 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.CursorClick
|
||||||
|
import pw.binom.rayneovm.shared.CursorMove
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground service на стороне очков.
|
* Foreground service на стороне очков.
|
||||||
@@ -119,7 +121,18 @@ class VmService : LifecycleService() {
|
|||||||
controller.launchGuest(msg.packageName)
|
controller.launchGuest(msg.packageName)
|
||||||
}
|
}
|
||||||
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}")
|
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}")
|
||||||
is VmKeyEvent -> Log.d(TAG, "Key: ${msg.keyCode}")
|
is CursorMove -> {
|
||||||
|
Log.d(TAG, "CursorMove: ${msg.x},${msg.y}")
|
||||||
|
controller.moveCursor(msg.x, msg.y)
|
||||||
|
}
|
||||||
|
is CursorClick -> {
|
||||||
|
Log.d(TAG, "CursorClick")
|
||||||
|
controller.clickCursor()
|
||||||
|
}
|
||||||
|
is VmKeyEvent -> {
|
||||||
|
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
|
||||||
|
controller.sendKey(msg.keyCode, msg.action)
|
||||||
|
}
|
||||||
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
|
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
|
||||||
is Shutdown -> {
|
is Shutdown -> {
|
||||||
Log.i(TAG, "Shutdown")
|
Log.i(TAG, "Shutdown")
|
||||||
|
|||||||
+64
-3
@@ -21,6 +21,7 @@ import pw.binom.mercury.shizuku.ShizukuVdBinder
|
|||||||
import pw.binom.rayneovm.glasses.VmShizukuService
|
import pw.binom.rayneovm.glasses.VmShizukuService
|
||||||
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
|
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
|
||||||
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
||||||
|
import pw.binom.rayneovm.shared.VmDisplay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контроллер состояния экрана очков и кадров.
|
* Контроллер состояния экрана очков и кадров.
|
||||||
@@ -35,6 +36,10 @@ class VmGlassesController {
|
|||||||
private val _frameSource = MutableStateFlow<FrameSource?>(null)
|
private val _frameSource = MutableStateFlow<FrameSource?>(null)
|
||||||
val frameSource: StateFlow<FrameSource?> = _frameSource.asStateFlow()
|
val frameSource: StateFlow<FrameSource?> = _frameSource.asStateFlow()
|
||||||
|
|
||||||
|
/** Позиция виртуального курсора мыши (координаты VirtualDisplay). null — курсор не показываем. */
|
||||||
|
private val _cursor = MutableStateFlow<CursorPosition?>(null)
|
||||||
|
val cursor: StateFlow<CursorPosition?> = _cursor.asStateFlow()
|
||||||
|
|
||||||
private var activeSource: VirtualDisplayFrameSource? = null
|
private var activeSource: VirtualDisplayFrameSource? = null
|
||||||
@Volatile private var appContext: Context? = null
|
@Volatile private var appContext: Context? = null
|
||||||
|
|
||||||
@@ -82,6 +87,7 @@ class VmGlassesController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
runCatching { ShizukuVdBinder.stopKillGuard(src.shizukuBinder) }
|
||||||
src.release()
|
src.release()
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "releaseFrameSource failed", t)
|
Log.w(TAG, "releaseFrameSource failed", t)
|
||||||
@@ -90,11 +96,51 @@ class VmGlassesController {
|
|||||||
_frameSource.value = null
|
_frameSource.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Обновляет состояние экрана. */
|
||||||
fun setState(newState: GlassesScreenState) {
|
fun setState(newState: GlassesScreenState) {
|
||||||
Log.i(TAG, "setState: $newState")
|
Log.i(TAG, "setState: $newState")
|
||||||
_state.value = newState
|
_state.value = newState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перемещает виртуальный курсор мыши. Очки рисуют его в обоих глазах, а [clickCursor]
|
||||||
|
* использует последнюю позицию как точку клика.
|
||||||
|
*/
|
||||||
|
fun moveCursor(x: Float, y: Float) {
|
||||||
|
_cursor.value = CursorPosition(
|
||||||
|
x = x.coerceIn(0f, VmDisplay.WIDTH - 1f),
|
||||||
|
y = y.coerceIn(0f, VmDisplay.HEIGHT - 1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клик мыши в текущей позиции курсора: инжектим гостю пару DOWN+UP ОДНОЙ транзакцией.
|
||||||
|
*
|
||||||
|
* Раньше DOWN и UP уходили отдельными сообщениями/корутинами — порядок не гарантирован,
|
||||||
|
* тач-поток у гостя разбалансировался и клик срабатывал ровно один раз.
|
||||||
|
*/
|
||||||
|
fun clickCursor() {
|
||||||
|
val src = activeSource ?: return
|
||||||
|
val pos = _cursor.value ?: return
|
||||||
|
val binder = src.shizukuBinder
|
||||||
|
val displayId = src.virtualDisplayId
|
||||||
|
testScope.launch {
|
||||||
|
val ok = ShizukuVdBinder.tap(binder, displayId, pos.x, pos.y)
|
||||||
|
if (!ok) Log.w(TAG, "clickCursor: tap failed (displayId=$displayId)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
|
||||||
|
fun sendKey(keyCode: Int, action: Int) {
|
||||||
|
val src = activeSource ?: return
|
||||||
|
val binder = src.shizukuBinder
|
||||||
|
val displayId = src.virtualDisplayId
|
||||||
|
testScope.launch {
|
||||||
|
val ok = ShizukuVdBinder.injectKeyEvent(binder, displayId, keyCode, action)
|
||||||
|
if (!ok) Log.w(TAG, "sendKey: injectKeyEvent failed (keyCode=$keyCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
|
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
|
||||||
* Используется для тестирования через adb broadcast.
|
* Используется для тестирования через adb broadcast.
|
||||||
@@ -108,6 +154,8 @@ class VmGlassesController {
|
|||||||
fun launchGuest(packageName: String) {
|
fun launchGuest(packageName: String) {
|
||||||
Log.i(TAG, "launchGuest: $packageName")
|
Log.i(TAG, "launchGuest: $packageName")
|
||||||
setState(GlassesScreenState.Running(packageName))
|
setState(GlassesScreenState.Running(packageName))
|
||||||
|
// Курсор появляется по центру экрана — иначе первый клик уйдёт «в никуда».
|
||||||
|
_cursor.value = CursorPosition(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)
|
||||||
|
|
||||||
// Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
|
// Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
|
||||||
// Без Shizuku acquireFrameSource вернёт null → гость не запустится.
|
// Без Shizuku acquireFrameSource вернёт null → гость не запустится.
|
||||||
@@ -131,6 +179,15 @@ class VmGlassesController {
|
|||||||
val pm = ctx2.packageManager
|
val pm = ctx2.packageManager
|
||||||
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-лаунчер по первой же проверке в forceStopApp (pkgName == mCurrentTopPkg)
|
||||||
|
// отменяет убийство. Иначе нас/гостя 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,
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
@@ -148,6 +205,7 @@ class VmGlassesController {
|
|||||||
fun stopGuest() {
|
fun stopGuest() {
|
||||||
Log.i(TAG, "stopGuest")
|
Log.i(TAG, "stopGuest")
|
||||||
releaseFrameSource()
|
releaseFrameSource()
|
||||||
|
_cursor.value = null
|
||||||
setState(GlassesScreenState.NoAppSelected)
|
setState(GlassesScreenState.NoAppSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +215,9 @@ class VmGlassesController {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "VmGlassesController"
|
private const val TAG = "VmGlassesController"
|
||||||
private const val VIRTUAL_WIDTH = 640
|
private const val VIRTUAL_WIDTH = VmDisplay.WIDTH
|
||||||
private const val VIRTUAL_HEIGHT = 480
|
private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
|
||||||
private const val VIRTUAL_DENSITY = 160
|
private const val VIRTUAL_DENSITY = VmDisplay.DENSITY
|
||||||
|
|
||||||
val Local = staticCompositionLocalOf<VmGlassesController> {
|
val Local = staticCompositionLocalOf<VmGlassesController> {
|
||||||
error("VmGlassesController not provided")
|
error("VmGlassesController not provided")
|
||||||
@@ -175,6 +233,9 @@ interface ControllerProvider {
|
|||||||
fun provideController(): VmGlassesController
|
fun provideController(): VmGlassesController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Позиция курсора мыши в координатах виртуального дисплея гостя. */
|
||||||
|
data class CursorPosition(val x: Float, val y: Float)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEBUG broadcast receiver для тестирования через adb.
|
* DEBUG broadcast receiver для тестирования через adb.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package pw.binom.rayneovm.glasses.ui
|
package pw.binom.rayneovm.glasses.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -15,7 +16,9 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -41,6 +44,7 @@ fun GlassesScreen(
|
|||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsState()
|
val state by controller.state.collectAsState()
|
||||||
val controllerFrameSource by controller.frameSource.collectAsState()
|
val controllerFrameSource by controller.frameSource.collectAsState()
|
||||||
|
val cursor by controller.cursor.collectAsState()
|
||||||
val noOp = remember { NoOpFrameSource() }
|
val noOp = remember { NoOpFrameSource() }
|
||||||
val frameSource = controllerFrameSource ?: noOp
|
val frameSource = controllerFrameSource ?: noOp
|
||||||
|
|
||||||
@@ -72,8 +76,8 @@ fun GlassesScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
is GlassesScreenState.Running -> {
|
is GlassesScreenState.Running -> {
|
||||||
// Ничего поверх GL — пользователь видит гостевой контент.
|
// Ничего поверх GL, кроме курсора мыши: FrameSource уже установлен контроллером.
|
||||||
// FrameSource уже установлен контроллером (launchGuest → controller.frameSource).
|
cursor?.let { CursorOverlay(it.x, it.y) }
|
||||||
}
|
}
|
||||||
is GlassesScreenState.Error -> StatusOverlay(
|
is GlassesScreenState.Error -> StatusOverlay(
|
||||||
title = "Ошибка",
|
title = "Ошибка",
|
||||||
@@ -83,6 +87,30 @@ fun GlassesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Курсор мыши поверх GL-слоя.
|
||||||
|
*
|
||||||
|
* Экран физически 1280×480: левая половина — левый глаз, правая — правый.
|
||||||
|
* Точку гостя (x, y) рисуем в обеих половинах на одном и том же y.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CursorOverlay(x: Float, y: Float) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val half = size.width / 2f
|
||||||
|
val radius = 10f
|
||||||
|
for (cx in floatArrayOf(x, x + half)) {
|
||||||
|
val center = Offset(cx, y)
|
||||||
|
drawCircle(color = Color(0xFFFFEB3B), radius = radius, center = center)
|
||||||
|
drawCircle(
|
||||||
|
color = Color(0xCC000000),
|
||||||
|
radius = radius,
|
||||||
|
center = center,
|
||||||
|
style = Stroke(width = 3f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatusOverlay(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) {
|
private fun StatusOverlay(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) {
|
||||||
val baseModifier = Modifier.fillMaxSize().background(Color.Black).padding(32.dp)
|
val baseModifier = Modifier.fillMaxSize().background(Color.Black).padding(32.dp)
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ import pw.binom.rayneovm.shared.AppCrashedEvent
|
|||||||
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.AppLaunchedAck
|
import pw.binom.rayneovm.shared.AppLaunchedAck
|
||||||
|
import pw.binom.rayneovm.shared.CursorClick
|
||||||
|
import pw.binom.rayneovm.shared.CursorMove
|
||||||
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.Pong
|
import pw.binom.rayneovm.shared.Pong
|
||||||
import pw.binom.rayneovm.shared.SetAudioMode
|
import pw.binom.rayneovm.shared.SetAudioMode
|
||||||
import pw.binom.rayneovm.shared.VmMessage
|
import pw.binom.rayneovm.shared.VmMessage
|
||||||
@@ -159,6 +162,18 @@ class VmPhoneController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Позиция курсора мыши в координатах VirtualDisplay — очки рисуют его в обоих глазах. */
|
||||||
|
suspend fun moveCursor(x: Float, y: Float) = send(CursorMove(x, y))
|
||||||
|
|
||||||
|
/** Клик мыши в текущей позиции курсора на очках. */
|
||||||
|
suspend fun clickCursor() = send(CursorClick)
|
||||||
|
|
||||||
|
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
|
||||||
|
suspend fun sendKey(keyCode: Int) {
|
||||||
|
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
|
||||||
|
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_UP))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
|
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
|
||||||
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
|
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Apps
|
import androidx.compose.material.icons.filled.Apps
|
||||||
|
import androidx.compose.material.icons.filled.Mouse
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -25,10 +26,11 @@ import pw.binom.rayneovm.phone.mercury.MercuryController
|
|||||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Главный экран приложения: NavigationBar с двумя табами.
|
* Главный экран приложения: NavigationBar с тремя табами.
|
||||||
*
|
*
|
||||||
* - «Очки» — статус связи и состояние GlassesApp
|
* - «Очки» — статус связи и состояние GlassesApp
|
||||||
* - «Приложения» — список приложений для запуска
|
* - «Приложения» — список приложений для запуска
|
||||||
|
* - «Пульт» — тачпад-мышь и медиа-кнопки для гостя
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
@@ -63,6 +65,7 @@ fun HomeScreen(
|
|||||||
onRequestMercuryPermissions = onRequestMercuryPermissions,
|
onRequestMercuryPermissions = onRequestMercuryPermissions,
|
||||||
)
|
)
|
||||||
Tab.APPS -> AppsListScreen(controller = controller)
|
Tab.APPS -> AppsListScreen(controller = controller)
|
||||||
|
Tab.REMOTE -> RemoteScreen(controller = controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,4 +75,5 @@ fun HomeScreen(
|
|||||||
private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
|
private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
|
||||||
GLASSES("Очки", Icons.Default.Settings),
|
GLASSES("Очки", Icons.Default.Settings),
|
||||||
APPS("Приложения", Icons.Default.Apps),
|
APPS("Приложения", Icons.Default.Apps),
|
||||||
|
REMOTE("Пульт", Icons.Default.Mouse),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||||
|
import pw.binom.rayneovm.shared.VmDisplay
|
||||||
|
|
||||||
|
// android.view.KeyEvent-коды. Здесь обычные числа, чтобы не тянуть android-зависимости в UI-тесты.
|
||||||
|
private const val KEYCODE_BACK = 4
|
||||||
|
private const val KEYCODE_MEDIA_PLAY = 126
|
||||||
|
private const val KEYCODE_MEDIA_PAUSE = 127
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пульт управления гостевым приложением на очках:
|
||||||
|
* - тачпад-мышь: движение пальца → курсор (отыгрывается на очках в обоих глазах),
|
||||||
|
* тап по тачпаду → клик в текущей позиции курсора;
|
||||||
|
* - кнопки «Назад», «Плей», «Пауза»;
|
||||||
|
* - «Клавиатура» — заглушка (сделаем позже).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RemoteScreen(
|
||||||
|
controller: VmPhoneController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Позиция курсора в координатах VirtualDisplay гостя.
|
||||||
|
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) }
|
||||||
|
|
||||||
|
fun click() {
|
||||||
|
scope.launch { controller.clickCursor() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().background(Color.Black).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(Color(0xFF141414))
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(onTap = { click() })
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures { change, drag ->
|
||||||
|
// Относительное движение: полный проход по тачпаду = весь экран гостя.
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Тачпад: ведите пальцем — курсор на очках.\nТап — клик по курсору.",
|
||||||
|
color = Color(0xFF666666),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
// Мини-индикатор положения курсора (для обратной связи на телефоне).
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val cx = cursor.x / VmDisplay.WIDTH * size.width
|
||||||
|
val cy = cursor.y / VmDisplay.HEIGHT * size.height
|
||||||
|
drawCircle(color = Color(0xFFFFEB3B), radius = 8f, center = Offset(cx, cy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
RemoteButton(Icons.AutoMirrored.Filled.ArrowBack, "Назад", Modifier.weight(1f)) {
|
||||||
|
scope.launch { controller.sendKey(KEYCODE_BACK) }
|
||||||
|
}
|
||||||
|
RemoteButton(Icons.Default.PlayArrow, "Плей", Modifier.weight(1f)) {
|
||||||
|
scope.launch { controller.sendKey(KEYCODE_MEDIA_PLAY) }
|
||||||
|
}
|
||||||
|
RemoteButton(Icons.Default.Pause, "Пауза", Modifier.weight(1f)) {
|
||||||
|
scope.launch { controller.sendKey(KEYCODE_MEDIA_PAUSE) }
|
||||||
|
}
|
||||||
|
// TODO: клавиатура — отдельная задача.
|
||||||
|
RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f), enabled = false) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RemoteButton(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(icon, contentDescription = label)
|
||||||
|
Text(label, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pw.binom.rayneovm.shared
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Параметры виртуального дисплея, на котором запускается гостевое приложение.
|
||||||
|
*
|
||||||
|
* Должны совпадать на телефоне и очках: телефон в этих координатах шлёт позицию курсора
|
||||||
|
* ([CursorMove]) и тач-события ([TouchAction]), очки в этих же координатах создают
|
||||||
|
* VirtualDisplay и рисуют курсор в обоих глазах.
|
||||||
|
*/
|
||||||
|
object VmDisplay {
|
||||||
|
const val WIDTH = 640
|
||||||
|
const val HEIGHT = 480
|
||||||
|
const val DENSITY = 160
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable
|
|||||||
* (настраивается в [VmProtocol]).
|
* (настраивается в [VmProtocol]).
|
||||||
*
|
*
|
||||||
* Потоки:
|
* Потоки:
|
||||||
* - Phone → Glasses: [TouchEvent], [KeyEvent], [LaunchAppCommand], [SetAudioMode], [Shutdown]
|
* - Phone → Glasses: [TouchEvent], [KeyEvent], [CursorMove], [CursorClick], [LaunchAppCommand], [SetAudioMode], [Shutdown]
|
||||||
* - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck]
|
* - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck]
|
||||||
* - Двунаправленные: [Ping], [Pong]
|
* - Двунаправленные: [Ping], [Pong]
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +55,30 @@ data class KeyEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиция виртуального курсора мыши в координатах виртуального дисплея гостя
|
||||||
|
* (0..[VmDisplay.WIDTH]-1, 0..[VmDisplay.HEIGHT]-1).
|
||||||
|
*
|
||||||
|
* Очки рисуют курсор в обоих глазах и запоминают позицию как цель для [CursorClick].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("cursor_move")
|
||||||
|
data class CursorMove(
|
||||||
|
val x: Float,
|
||||||
|
val y: Float,
|
||||||
|
) : VmMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клик виртуальной мыши в текущей позиции курсора ([CursorMove]).
|
||||||
|
*
|
||||||
|
* Отдельных DOWN/UP нет намеренно: очки инжектят пару DOWN+UP одной транзакцией
|
||||||
|
* (см. `ShizukuVdBinder.tap`). Двумя независимыми сообщениями порядок не гарантирован:
|
||||||
|
* тач-поток у гостя разбалансируется («залипший» палец) и клики перестают работать.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("cursor_click")
|
||||||
|
data object CursorClick : VmMessage
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("launch_app")
|
@SerialName("launch_app")
|
||||||
data class LaunchAppCommand(
|
data class LaunchAppCommand(
|
||||||
|
|||||||
@@ -46,6 +46,28 @@ class VmProtocolTest {
|
|||||||
assertEquals(msg, decoded)
|
assertEquals(msg, decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `CursorMove roundtrip`() {
|
||||||
|
val msg = CursorMove(x = 123.5f, y = 456.25f)
|
||||||
|
val encoded = VmProtocol.encode(msg)
|
||||||
|
val decoded = VmProtocol.decode<CursorMove>(encoded)
|
||||||
|
assertEquals(msg, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `CursorClick roundtrip`() {
|
||||||
|
val decoded = VmProtocol.decode<CursorClick>(VmProtocol.encode(CursorClick))
|
||||||
|
assertEquals(CursorClick, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodeAny for CursorMove uses type discriminator`() {
|
||||||
|
val msg = VmProtocol.decodeAny("""{"type":"cursor_move","x":10.5,"y":20.25}""")
|
||||||
|
assertTrue(msg is CursorMove)
|
||||||
|
assertEquals(10.5f, msg.x)
|
||||||
|
assertEquals(20.25f, msg.y)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GlassesStatus roundtrip`() {
|
fun `GlassesStatus roundtrip`() {
|
||||||
val msg = GlassesStatus(
|
val msg = GlassesStatus(
|
||||||
|
|||||||
Reference in New Issue
Block a user