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

This commit is contained in:
2026-09-25 19:49:25 +03:00
parent 3fce3eb3ca
commit c69e8a2e80
8 changed files with 544 additions and 26 deletions
@@ -32,6 +32,7 @@ import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
@@ -84,6 +85,14 @@ class VmService : LifecycleService() {
}
}
/**
* Текущая активная сессия телефона.
*
* Нужна, чтобы отключение «чужой»/устаревшей сессии (например, пробного WebSocket-хендшейка
* из subnet-фоллбека при поиске очков) не сбрасывало экран в NoConnection, пока жива настоящая.
*/
private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null)
private fun startServer() {
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
@@ -94,10 +103,15 @@ class VmService : LifecycleService() {
},
onConnected = { connId ->
Log.i(TAG, "Phone connected: $connId")
currentConn.set(connId)
},
onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId")
controller.setState(GlassesScreenState.NoConnection)
if (currentConn.compareAndSet(connId, null)) {
controller.setState(GlassesScreenState.NoConnection)
} else {
Log.i(TAG, "Ignoring disconnect of stale session $connId")
}
},
)
} catch (t: Throwable) {
@@ -129,6 +143,10 @@ class VmService : LifecycleService() {
Log.d(TAG, "CursorClick")
controller.clickCursor()
}
is CursorScroll -> {
Log.d(TAG, "CursorScroll: ${msg.dx},${msg.dy}")
controller.scrollCursor(msg.dx, msg.dy)
}
is VmKeyEvent -> {
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
controller.sendKey(msg.keyCode, msg.action)
@@ -5,14 +5,19 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.SurfaceTexture
import android.os.IBinder
import android.util.Log
import android.view.Surface
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -41,6 +46,15 @@ class VmGlassesController {
val cursor: StateFlow<CursorPosition?> = _cursor.asStateFlow()
private var activeSource: VirtualDisplayFrameSource? = null
/** Служебный дисплей Mercury-«стража» (см. [startMercuryKillGuard]); 0 — не создан. */
private var guardDisplayId = 0
/** Surface служебного дисплея: без валидной Surface дисплей считается OFF и активность не стартует. */
private var guardSurfaceTexture: SurfaceTexture? = null
/** Watchdog: переподнимает активность стража, если она завершилась сама. */
private var guardWatchdog: Job? = null
@Volatile private var appContext: Context? = null
/** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
@@ -87,7 +101,7 @@ class VmGlassesController {
return
}
try {
runCatching { ShizukuVdBinder.stopKillGuard(src.shizukuBinder) }
runCatching { stopMercuryKillGuard(src.shizukuBinder) }
src.release()
} catch (t: Throwable) {
Log.w(TAG, "releaseFrameSource failed", t)
@@ -130,6 +144,33 @@ class VmGlassesController {
}
}
/**
* Скролл в текущей позиции курсора: [dx]/[dy] — смещение пальцев в пикселях координат
* гостя (два пальца по тачпаду). Инжектим колесо мыши (`ACTION_SCROLL`) в точку курсора.
*
* Гость умножает axis-значение на `ViewConfiguration.scaledVerticalScrollFactor`
* (≈ [SCROLL_FACTOR]px при density 160), поэтому делим на него: смещение становится
* 1:1 с движением пальца.
*/
fun scrollCursor(dx: Float, dy: Float) {
if (dx == 0f && dy == 0f) return
val src = activeSource ?: return
val pos = _cursor.value ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
testScope.launch {
val ok = ShizukuVdBinder.scroll(
binder = binder,
displayId = displayId,
x = pos.x,
y = pos.y,
hScroll = dx / SCROLL_FACTOR,
vScroll = dy / SCROLL_FACTOR,
)
if (!ok) Log.w(TAG, "scrollCursor: scroll failed (displayId=$displayId)")
}
}
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
fun sendKey(keyCode: Int, action: Int) {
val src = activeSource ?: return
@@ -180,13 +221,8 @@ class VmGlassesController {
val launchIntent = pm.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: ""
// Пока гость работает, держим «страж»: Settings.Secure.top_app = наш пакет.
// Mercury-лаунчер по первой же проверке в forceStopApp (pkgName == mCurrentTopPkg)
// отменяет убийство. Иначе нас/гостя force-стопит через ~2 c после смены top-app.
// Подробный разбор — в ShizukuVdBinder.startKillGuard.
if (!ShizukuVdBinder.startKillGuard(src.shizukuBinder, ctx2.packageName)) {
Log.w(TAG, "launchGuest: не удалось запустить Mercury kill guard")
}
// Пока гость работает — держим выключенным Mercury-«убийцу» (BackgroundAppManager).
startMercuryKillGuard(src.shizukuBinder)
val ok = ShizukuVdBinder.launchActivity(
binder = src.shizukuBinder,
@@ -209,6 +245,91 @@ class VmGlassesController {
setState(GlassesScreenState.NoAppSelected)
}
/**
* Выключает Mercury-«убийцу» фоновых приложений (`BackgroundAppManager`) на время работы гостя.
*
* У RayNeo политика «одно приложение на переднем плане»: на каждой смене `Secure.top_app`
* лаунчер force-стопит все пакеты из накопительной очереди предыдущих топов. Гость на
* VirtualDisplay тоже считается топом, поэтому лаунчер убивал то наше приложение, то гостя
* (снаружи выглядело как «клик работает ровно один раз»).
*
* Легальный выключатель — вендорский `BackgroundAppManager.enableStopBackgroundKill(true)`.
* Его ставит `NetConfigActivity.onStart` и снимает в `onStop`, поэтому держим эту активность
* запущенной на отдельном невидимом VirtualDisplay: там её никто не перекрывает, флаг
* остаётся true, и `forceStopApp` отменяется на проверке `tmpStopBackgroundKill`.
*
* Почему не через `top_app` и не через провайдер: `Settings.Secure.top_app` лаунчер сам
* переписывает по реальному топу, а `ContentProvider.getCallingPackage()` на API 31+
* сверяет пакет с реальным uid вызывающего — shell (uid 2000) не может представиться
* `com.leiniao.camera`.
*/
private suspend fun startMercuryKillGuard(binder: IBinder) {
if (guardDisplayId > 0) return
val surfaceTexture = try {
// Дисплей с невалідной Surface считается OFF, и активность на нём не стартует.
SurfaceTexture(false).also { it.setDefaultBufferSize(GUARD_WIDTH, GUARD_HEIGHT) }
} catch (t: Throwable) {
Log.w(TAG, "startMercuryKillGuard: SurfaceTexture failed", t)
return
}
val displayId = try {
ShizukuVdBinder.createDisplay(
binder = binder,
name = GUARD_DISPLAY_NAME,
width = GUARD_WIDTH,
height = GUARD_HEIGHT,
density = VIRTUAL_DENSITY,
surface = Surface(surfaceTexture),
)
} catch (t: Throwable) {
Log.w(TAG, "startMercuryKillGuard: createDisplay failed", t)
surfaceTexture.release()
return
}
if (displayId <= 0) {
Log.w(TAG, "startMercuryKillGuard: createDisplay вернул displayId=$displayId")
surfaceTexture.release()
return
}
guardDisplayId = displayId
guardSurfaceTexture = surfaceTexture
launchGuardActivity(binder, displayId)
// Даём активности дойти до onStart: именно там включается флаг. Иначе первая же
// смена top_app (запуск гостя) успеет попасть в forceStopApp до включения флага.
delay(GUARD_WARMUP_MS)
guardWatchdog = testScope.launch {
while (isActive) {
delay(GUARD_RELAUNCH_MS)
launchGuardActivity(binder, displayId)
}
}
Log.i(TAG, "startMercuryKillGuard: страж поднят на displayId=$displayId")
}
private fun launchGuardActivity(binder: IBinder, displayId: Int) {
runCatching {
ShizukuVdBinder.launchActivity(
binder = binder,
packageName = GUARD_PACKAGE,
className = GUARD_ACTIVITY,
displayId = displayId,
)
}.onFailure { Log.w(TAG, "launchGuardActivity failed (displayId=$displayId)", it) }
}
/** Снимает стража: останавливает watchdog и освобождает служебный дисплей. */
private fun stopMercuryKillGuard(binder: IBinder) {
guardWatchdog?.cancel()
guardWatchdog = null
val displayId = guardDisplayId
guardDisplayId = 0
guardSurfaceTexture?.release()
guardSurfaceTexture = null
if (displayId > 0) {
runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
}
}
fun setAppContext(context: Context) {
this.appContext = context.applicationContext
}
@@ -219,6 +340,24 @@ class VmGlassesController {
private const val VIRTUAL_HEIGHT = VmDisplay.HEIGHT
private const val VIRTUAL_DENSITY = VmDisplay.DENSITY
/** `ViewConfiguration.scaledVerticalScrollFactor` при density 160: гость умножает axis на это. */
private const val SCROLL_FACTOR = 64f
/** Активность RayNeo-лаунчера, чей `onStart` включает вендорский `tmpStopBackgroundKill`. */
private const val GUARD_PACKAGE = "com.ffalconxr.mercury.launcher"
private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
private const val GUARD_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240
/** Как часто перепроверяем, что активность стража жива. */
private const val GUARD_RELAUNCH_MS = 5_000L
/** Пауза после старта стража: даём `onStart` выставить флаг до запуска гостя. */
private const val GUARD_WARMUP_MS = 1_500L
val Local = staticCompositionLocalOf<VmGlassesController> {
error("VmGlassesController not provided")
}