Files
rayneo-vm/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt
T
subochev d4e1b66924 аудио 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
2026-10-01 05:54:54 +03:00

230 lines
11 KiB
Kotlin

package pw.binom.rayneovm.glasses
import android.app.Activity
import android.content.Intent
import android.content.IntentFilter
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
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.Lifecycle
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
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).
*/
private val projectionLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
val mp = getSystemService(MediaProjectionManager::class.java)
.getMediaProjection(result.resultCode, data)
if (mp != null) {
Log.i(TAG, "MediaProjection получен")
AudioBridge.onProjectionGranted(mp)
} else {
Log.w(TAG, "getMediaProjection вернул null")
}
} else {
Log.w(TAG, "MediaProjection не выдан: resultCode=${result.resultCode}")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// HOME живёт на display 0. Если систему (или гостя на VirtualDisplay)
// угораздит запросить домашний экран внутри чужого дисплея — мы бы
// открылись сами внутри себя (зеркальная рекурсия), поэтому выходим.
val displayId = display?.displayId ?: Display.DEFAULT_DISPLAY
if (displayId != Display.DEFAULT_DISPLAY) {
finish()
return
}
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.
//
// handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе
// Activity в RESUMED (а после диалога проекции мы как раз возвращаемся в RESUMED).
// Без него один и тот же tick запускал бы запрос повторно.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
AudioBridge.requestTick.collect { tick ->
if (tick > handledTick) {
handledTick = tick
launchProjectionRequest()
}
}
}
}
val controller = VmGlassesController.fromContext(this)
this.controller = controller
setContent {
CompositionLocalProvider(VmGlassesController.Local provides controller) {
MaterialTheme(colorScheme = darkColorScheme()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
val shizukuState by ShizukuBootstrap.state.collectAsState()
DriveShizukuState(controller, shizukuState)
GlassesScreen(
controller = controller,
onRequestShizukuPermission = { ShizukuBootstrap.refresh() },
)
}
}
}
}
}
override fun onResume() {
super.onResume()
AudioBridge.activityResumed = true
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()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == ShizukuBootstrap.REQUEST_CODE_PERMISSION) {
val grant = grantResults.firstOrNull() ?: -1
ShizukuBootstrap.onRequestPermissionResult(requestCode, grant)
}
}
private fun launchProjectionRequest() {
runCatching {
val mpm = getSystemService(MediaProjectionManager::class.java)
projectionLauncher.launch(mpm.createScreenCaptureIntent())
}.onFailure { Log.w(TAG, "Не удалось запустить запрос MediaProjection: ${it.message}") }
}
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"
}
}
@Composable
private fun DriveShizukuState(controller: VmGlassesController, shizuku: ShizukuBootstrap.AllowState) {
LaunchedEffect(shizuku) {
when (shizuku) {
ShizukuBootstrap.AllowState.NotRunning,
ShizukuBootstrap.AllowState.Waiting,
ShizukuBootstrap.AllowState.NeedsPermission -> {
if (controller.state.value !is GlassesScreenState.Running) {
controller.setState(GlassesScreenState.ShizukuMissing)
}
}
ShizukuBootstrap.AllowState.Granted -> {
// Если мы сейчас в ShizukuMissing — переходим в NoAppSelected.
// Running не трогаем: приложение уже запущено, Shizuku-статус не должен его ронять.
if (controller.state.value is GlassesScreenState.ShizukuMissing) {
controller.setState(GlassesScreenState.NoAppSelected)
}
}
}
}
}