аудио UDP+Opus (очки→телефон), жесты дужки → гость, «убить всё лишнее» на очках

- shared/audio: вендорнут Concentus (libs/concentus.jar), OpusCodec/AudioDatagram/AudioConfig;
  захват на очках 48кГц стерео → Opus → UDP-датаграммы вместо сырого PCM по WebSocket
- glass.audio: AudioRecord 48кГц с минимальным буфером; UDP-сервер запоминает адрес пира из датаграммы
- glasses: жесты тач-сенсора дужки (cyttsp5_mt) через dispatchTouchEvent → DPAD/BACK гостю (TempleGesture*)
- glasses: forceStopAll — force-stop сторонних + xr.runtime, kill scrcpy-сирот через Shizuku UserService
- phone: AudioPlaybackManager на UDP+Opus+low-latency AudioTrack; кнопка «Убить всё лишнее» на вкладке очков
- deps: mercury transport 0.1.2-SNAPSHOT, shizuku-vd 0.1.1-SNAPSHOT
This commit is contained in:
2026-10-01 05:54:54 +03:00
parent 8cfd9192b3
commit d4e1b66924
16 changed files with 744 additions and 90 deletions
@@ -7,6 +7,8 @@ import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -29,6 +31,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.input.TempleGestureDetector
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.ui.GlassesScreen
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -38,6 +41,26 @@ class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L
/** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */
private lateinit var controller: VmGlassesController
/**
* Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство
* `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и
* перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через
* [VmGlassesController.sendTempleGesture].
*/
private val templeGestureDetector by lazy {
TempleGestureDetector { gesture -> controller.sendTempleGesture(gesture) }
}
/** id input-устройства дужки (`cyttsp5_mt`); -1 — не найдено. */
private val templeDeviceId: Int by lazy {
InputDevice.getDeviceIds().firstOrNull { id ->
InputDevice.getDevice(id)?.name?.contains(TEMPLE_DEVICE_HINT) == true
} ?: -1
}
/**
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
@@ -71,6 +94,13 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
ShizukuBootstrap.attach(this)
// Диагностика: печатаем input-устройства и id дужки, чтобы в логе было видно,
// куда приходят жесты (см. dispatchTouchEvent).
InputDevice.getDeviceIds().forEach { id ->
val d = InputDevice.getDevice(id)
Log.i(TAG, "input device id=$id name=${d?.name} sources=0x${d?.sources?.toString(16)}")
}
Log.i(TAG, "temple device id=$templeDeviceId (hint='$TEMPLE_DEVICE_HINT')")
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
@@ -89,6 +119,7 @@ class MainActivity : ComponentActivity() {
}
}
val controller = VmGlassesController.fromContext(this)
this.controller = controller
setContent {
CompositionLocalProvider(VmGlassesController.Local provides controller) {
@@ -116,6 +147,29 @@ class MainActivity : ComponentActivity() {
ShizukuBootstrap.refresh()
}
/**
* Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки
* (`cyttsp6_mt`) не ассоциированы с дисплеем и иначе теряются: `onTouchEvent` Activity
* при этом может не вызываться вовсе. Всё, что пришло с дужки, отдаём распознавателю и
* потребляем, чтобы не утекло в UI.
*/
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// Диагностика: на ACTION_DOWN печатаем устройство — видно, доходят ли касания дужки
// (deviceId тача дужки = 4, `cyttsp5_mt`) до окна Activity вообще.
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
Log.d(
TAG,
"touch down: deviceId=${event.deviceId} source=0x${event.source.toString(16)} " +
"x=${event.x} y=${event.y}",
)
}
val isTemple = event.deviceId == templeDeviceId && templeDeviceId != -1
if (!isTemple) return super.dispatchTouchEvent(event)
Log.d(TAG, "temple: action=${event.actionMasked} x=${event.x} y=${event.y}")
templeGestureDetector.onTouchEvent(event)
return true
}
override fun onPause() {
AudioBridge.activityResumed = false
super.onPause()
@@ -142,6 +196,13 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
/**
* Имя input-устройства тач-сенсора дужки RayNeo X3 Pro (проверено через `getevent`:
* все касания дужки приходят на `/dev/input/event2`, устройство `cyttsp5_mt`).
* Второй контроллер `cyttsp6_mt` не генерирует событий.
*/
private const val TEMPLE_DEVICE_HINT = "cyttsp5_mt"
}
}
@@ -38,6 +38,7 @@ import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll
@@ -46,6 +47,9 @@ import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioEncoder
/**
* Foreground service на стороне очков.
@@ -73,16 +77,22 @@ class VmService : LifecycleService() {
private var audioCapture: GlassesAudioCapture? = null
private var audioEnsureJob: kotlinx.coroutines.Job? = null
/** Opus-кодер: используется только из аудио-пампа, поэтому не нужна синхронизация. */
private val audioEncoder = OpusAudioEncoder()
/** seq аудио-датаграмм; оборачивается через переполнение Int — это нормально. */
private var audioSeq = 0
@Volatile
private var audioMode: AudioMode = AudioMode.ON_GLASSES
/**
* Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет.
* При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть,
* чем полнота (иначе задержка растёт безгранично).
* Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
* [AudioRecord] и Opus-кодером. Крошечная ёмкость + DROP_OLDEST: для live-аудио
* важнее свежесть, чем полнота — иначе задержка растёт безгранично.
*/
private val audioFrames = Channel<ByteArray>(
capacity = 16,
private val audioFrames = Channel<ShortArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@@ -230,6 +240,10 @@ class VmService : LifecycleService() {
Log.i(TAG, "Shutdown")
controller.stopGuest()
}
is ForceStopAll -> {
Log.i(TAG, "ForceStopAll")
controller.forceStopAll()
}
is GetAppList -> {
Log.i(TAG, "GetAppList")
val apps = listInstalledLaunchableApps()
@@ -250,20 +264,26 @@ class VmService : LifecycleService() {
// ---------------- Аудио-мост (задача #2) ----------------
/** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */
/**
* UDP-сервер аудио. Телефон шлёт сюда keepalive/подписку, мы запоминаем адрес
* источника (`ip:port`) как пира и отвечаем Opus-датаграммами.
*
* IP телефона НЕ константа — он берётся из адреса входящей датаграммы. В реальной жизни
* очки подключены к точке доступа самого телефона, адреса меняются от сессии к сессии.
*/
private fun startAudioServer() {
audioServer = Transports.wifiServerBytes(port = AUDIO_PORT, path = AUDIO_PATH)
audioServer = Transports.udpServer(port = AudioConfig.UDP_PORT)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
audioServer!!.accept(
onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ },
onMessage = { _, _ -> /* keepalive/подписка: содержимое не важно, важен источник */ },
onConnected = { connId ->
audioSessions.add(connId)
Log.i(TAG, "Audio client connected: $connId")
Log.i(TAG, "Audio peer connected: $connId")
},
onDisconnected = { connId ->
audioSessions.remove(connId)
Log.i(TAG, "Audio client disconnected: $connId")
Log.i(TAG, "Audio peer disconnected: $connId")
},
)
} catch (t: Throwable) {
@@ -272,14 +292,24 @@ class VmService : LifecycleService() {
}
}
/** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */
/** Кодирует кадры PCM в Opus и рассылает датаграммы всем подключённым аудио-пирам. */
private fun startAudioPump() {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
for (bytes in audioFrames) {
val packet = ByteArray(AudioConfig.MAX_DATAGRAM)
for (pcm in audioFrames) {
val server = audioServer ?: continue
if (audioSessions.isEmpty()) continue
val payload = try {
audioEncoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
} catch (t: Throwable) {
Log.w(TAG, "opus encode failed: ${t.message}")
continue
}
val len = AudioDatagram.pack(audioSeq++, payload, payload.size, packet)
val datagram = packet.copyOf(len)
for (connId in audioSessions) {
try {
server.send(connId, bytes)
server.send(connId, datagram)
} catch (t: Throwable) {
Log.w(TAG, "audio send failed: ${t.message}")
}
@@ -538,10 +568,6 @@ class VmService : LifecycleService() {
const val WS_PORT = 8080
const val WS_PATH = "/vm"
/** Отдельный порт/путь для PCM-аудио (задача #2). Телефон знает их по константе. */
const val AUDIO_PORT = 8081
const val AUDIO_PATH = "/vm-audio"
/** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
private const val AUDIO_ENSURE_MS = 2_500L
@@ -11,21 +11,25 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.rayneovm.shared.audio.AudioConfig
/**
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
*
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
*
* Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS
* в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
* Пишет 48 кГц stereo 16-bit кадрами ровно по [AudioConfig.FRAME_MS] мс и отдаёт их
* в [sink] на `Dispatchers.IO` как `ShortArray` (interleaved). Кадр фиксированного
* размера нужен Opus-кодеру: он кодирует ровно [AudioConfig.FRAME_SAMPLES] сэмплов/канал.
*
* Раз в ~1 c пишется RMS в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
*
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
*/
class GlassesAudioCapture(
private val projection: MediaProjection,
private val sink: suspend (ByteArray) -> Unit,
private val sink: suspend (ShortArray) -> Unit,
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
private val onStopped: (() -> Unit)? = null,
) {
@@ -35,7 +39,7 @@ class GlassesAudioCapture(
fun start(scope: CoroutineScope): Boolean {
if (running) return true
val sampleRate = SAMPLE_RATE
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -44,7 +48,9 @@ class GlassesAudioCapture(
Log.w(TAG, "getMinBufferSize=$minBuf")
return false
}
val bufferSize = minBuf * 4
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше двух кадров,
// чтобы мгновенный джиттер планировщика не приводил к overrun/потере сэмплов.
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 2)
val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
@@ -78,21 +84,26 @@ class GlassesAudioCapture(
running = true
rec.startRecording()
job = scope.launch(Dispatchers.IO) {
val buf = ByteArray(FRAME_BYTES)
val frame = ShortArray(AudioConfig.FRAME_SHORTS)
var filled = 0
var frameCount = 0
var totalBytes = 0L
var totalSamples = 0L
while (isActive && running) {
val read = rec.read(buf, 0, buf.size)
val read = rec.read(frame, filled, frame.size - filled)
if (read > 0) {
try {
sink(buf.copyOf(read))
} catch (t: Throwable) {
Log.w(TAG, "sink упал: ${t.message}")
}
totalBytes += read
frameCount++
if (frameCount % 40 == 0) {
Log.i(TAG, "capture: bytes=$totalBytes rms=${rms16(buf, read).toInt()}")
filled += read
if (filled == frame.size) {
try {
sink(frame.copyOf())
} catch (t: Throwable) {
Log.w(TAG, "sink упал: ${t.message}")
}
totalSamples += filled
frameCount++
if (frameCount % 100 == 0) {
Log.i(TAG, "capture: samples=$totalSamples rms=${rms16(frame).toInt()}")
}
filled = 0
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read ошибка: $read")
@@ -105,7 +116,7 @@ class GlassesAudioCapture(
onStopped?.invoke()
}
}
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize")
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, кадр=${AudioConfig.FRAME_MS}мс, buffer=$bufferSize")
return true
}
@@ -126,23 +137,16 @@ class GlassesAudioCapture(
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
}
private fun rms16(buf: ByteArray, len: Int): Double {
if (len < 2) return 0.0
private fun rms16(buf: ShortArray): Double {
if (buf.isEmpty()) return 0.0
var sumSq = 0.0
var i = 0
while (i + 1 < len) {
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
for (s in buf) {
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (len / 2))
return Math.sqrt(sumSq / buf.size)
}
companion object {
private const val TAG = "GlassesAudioCapture"
const val SAMPLE_RATE = 44100
/** ~23 мс @ 44.1 кГц 16-bit stereo — маленькая задержка без перегрузки кадрами. */
private const val FRAME_BYTES = 4096
}
}
@@ -0,0 +1,117 @@
package pw.binom.rayneovm.glasses.input
/**
* Жест, распознанный на тач-сенсоре дужки RayNeo X3 Pro.
*
* SDK RayNeo ([com.rayneo.arsdk.android.touch.TempleAction]) даёт похожий набор, но мы
* распознаём сами: логика тривиальна, а обфусцированный `TouchDispatcher` тянуть не хочется.
*
* Дужка на X3 Pro — отдельное input-устройство `cyttsp5_mt` (`/dev/input/event2`), у которого
* НЕТ ассоциированного дисплея (`AssociatedDisplay: displayId=''`), поэтому события приходят
* в окно Activity, но Compose/View-иерархия их не интерпретирует как касание. Перехватываем
* их в [android.app.Activity.dispatchTouchEvent] и превращаем в [TempleGesture].
*/
sealed interface TempleGesture {
/** Короткое одиночное касание. */
data object Click : TempleGesture
/** Два касания подряд — по умолчанию «назад» в госте. */
data object DoubleClick : TempleGesture
/**
* Завершённый свайп по дужке с итоговым смещением.
*
* @param dx смещение по X в пикселях сенсора (дужка — длина тач-зоны, ось «вдоль»).
* @param dy смещение по Y в пикселях сенсора (ось «поперёк»).
* @param velocity приблизительная скорость, px/с.
*/
data class Swipe(val dx: Float, val dy: Float, val velocity: Float) : TempleGesture
}
/**
* Распознаватель жестов тач-сенсора дужки.
*
* Питается обычными [android.view.MotionEvent], отфильтрованными по устройству дужки
* (см. `MainActivity.TEMPLE_DEVICE_HINT`). Классификация на ACTION_UP:
* - путь < [TAP_SLOP_PX] и время < [LONG_PRESS_MS] → [TempleGesture.Click];
* - путь ≥ [SLIDE_SLOP_PX] → [TempleGesture.Swipe].
*
* Двойной клик собирается по двум [TempleGesture.Click] подряд с интервалом ≤ [DOUBLE_TAP_MS]:
* второй превращается в [TempleGesture.DoubleClick], первый «съедается» ожиданием.
*
* Состояния между касаниями почти нет (только таймер двойного клика). Вызывать строго
* с UI-потока (`dispatchTouchEvent`).
*/
class TempleGestureDetector(
private val onGesture: (TempleGesture) -> Unit,
) {
private var downX = 0f
private var downY = 0f
private var downTime = 0L
/**
* Одиночный клик откладываем на [DOUBLE_TAP_MS]: если второй тап придёт в окно, первый
* превратится в [TempleGesture.DoubleClick], а не в отдельный [TempleGesture.Click].
* Без этой задержки двойной тап давал бы сначала лишний «выбор», и только потом «назад».
*/
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
private var clickPending = false
private val emitPendingClick = Runnable {
clickPending = false
onGesture(TempleGesture.Click)
}
fun onTouchEvent(event: android.view.MotionEvent) {
when (event.actionMasked) {
android.view.MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
downTime = event.eventTime
}
android.view.MotionEvent.ACTION_UP -> {
val dt = event.eventTime - downTime
val dx = event.x - downX
val dy = event.y - downY
val distance = kotlin.math.hypot(dx, dy)
if (distance < TAP_SLOP_PX && dt < LONG_PRESS_MS) {
if (clickPending) {
handler.removeCallbacks(emitPendingClick)
clickPending = false
onGesture(TempleGesture.DoubleClick)
} else {
clickPending = true
handler.postDelayed(emitPendingClick, DOUBLE_TAP_MS)
}
} else if (distance >= SLIDE_SLOP_PX) {
cancelPendingClick()
val velocity = distance / dt.coerceAtLeast(1L).toFloat() * 1000f
onGesture(TempleGesture.Swipe(dx = dx, dy = dy, velocity = velocity))
}
}
android.view.MotionEvent.ACTION_CANCEL -> {
downTime = 0L
}
}
}
private fun cancelPendingClick() {
handler.removeCallbacks(emitPendingClick)
}
companion object {
/** Движение короче этого считаем касанием, а не свайпом (в пикселях сенсора). */
const val TAP_SLOP_PX = 40f
/** Смещение, с которого касание становится свайпом. */
const val SLIDE_SLOP_PX = 80f
/** Нажатие дольше этого — не клик (пока не обрабатываем, резерв). */
const val LONG_PRESS_MS = 600L
/**
* Максимальный интервал между двумя кликами для склейки в двойной и одновременно
* задержка одиночного клика (см. [emitPendingClick]).
*/
const val DOUBLE_TAP_MS = 300L
}
}
@@ -27,6 +27,7 @@ import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.mercury.shizuku.ShizukuVdClient
import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.input.TempleGesture
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.VmDisplay
@@ -330,6 +331,49 @@ class VmGlassesController {
}
}
/**
* Жест тач-сенсора дужки → событие гостю на VirtualDisplay.
*
* Маппинг — DPAD-клавиши (крестовина джойстика, которую понимает гость, например ZONA):
* - [TempleGesture.Swipe] с доминирующей осью X → LEFT/RIGHT, осью Y → UP/DOWN;
* - [TempleGesture.DoubleClick] → KEYCODE_BACK («назад»);
* - [TempleGesture.Click] → KEYCODE_DPAD_CENTER (подтверждение).
*
* Знаки осей (какой свайп = «вверх») подбираются эмпирически на устройстве.
* Каждому событию инжектим DOWN+UP парой, как одиночное нажатие клавиши.
*/
fun sendTempleGesture(gesture: TempleGesture) {
val src = activeSource ?: run {
Log.d(TAG, "sendTempleGesture($gesture): нет активного гостя")
return
}
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
val keyCode: Int = when (gesture) {
is TempleGesture.Swipe -> {
if (kotlin.math.abs(gesture.dx) >= kotlin.math.abs(gesture.dy)) {
if (gesture.dx >= 0f) android.view.KeyEvent.KEYCODE_DPAD_RIGHT
else android.view.KeyEvent.KEYCODE_DPAD_LEFT
} else {
if (gesture.dy >= 0f) android.view.KeyEvent.KEYCODE_DPAD_DOWN
else android.view.KeyEvent.KEYCODE_DPAD_UP
}
}
is TempleGesture.DoubleClick -> android.view.KeyEvent.KEYCODE_BACK
is TempleGesture.Click -> android.view.KeyEvent.KEYCODE_DPAD_CENTER
}
Log.i(TAG, "sendTempleGesture: $gesture -> keyCode=$keyCode")
testScope.launch {
val down = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_DOWN,
)
val up = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_UP,
)
if (!down || !up) Log.w(TAG, "sendTempleGesture: injectKeyEvent failed (keyCode=$keyCode)")
}
}
/**
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast.
@@ -413,6 +457,43 @@ class VmGlassesController {
}
}
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + `kill -9` процессов-сирот вроде scrcpy.
*
* Выполняется нашим Shizuku UserService от shell UID. Защищены: сам GlassesApp, Shizuku
* (через него же и работаем) и активный гость на VirtualDisplay — по просьбе пользователя
* его не трогаем. Дисплей гостя при этом не освобождаем: убьём только процессы вокруг.
*/
fun forceStopAll() {
testScope.launch {
// Страж живёт постоянно (watchdog), его binder — надёжный долгоживущий вариант.
// Если стража нет, берём binder активного гостя.
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "forceStopAll: нет Shizuku binder (стража нет, гостя нет)")
return@launch
}
val protected = buildList {
add(GLASSES_PACKAGE)
add(SHIZUKU_PACKAGE)
activeGuestPackage?.let { add(it) }
}
val killed = runCatching {
ShizukuVdBinder.forceStopAll(
binder = binder,
protectedPackages = protected,
extraPackages = listOf(XR_RUNTIME_PACKAGE),
processNameSubstrings = listOf(SCRCPY_PROCESS),
)
}.getOrElse {
Log.w(TAG, "forceStopAll failed", it)
-1
}
Log.i(TAG, "forceStopAll: killed=$killed (protected=$protected)")
}
}
/**
* Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex].
*
@@ -619,6 +700,21 @@ class VmGlassesController {
private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
/** Наш пакет — не убиваем себя при «убить всё» (см. [forceStopAll]). */
private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
/** Shizuku — не убиваем: через его UserService и выполняем force-stop. */
private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
/**
* RayNeo XR-runtime. Формально системный (`system_ext/priv-app`), но именно он держит
* открытой аппаратную камеру и жрёт ~100% CPU — поэтому его принудительно снимаем.
*/
private const val XR_RUNTIME_PACKAGE = "com.rayneo.xr.runtime"
/** Подстрока командной строки процессов-зеркал (scrcpy), которые тоже добиваем. */
private const val SCRCPY_PROCESS = "scrcpy"
private const val GUARD_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240