d4e1b66924
- 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
230 lines
11 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|